Skip to content

Advance skipped steps in a loop instead of recursing - #983

Open
theluckystrike wants to merge 1 commit into
stadiamaps:mainfrom
theluckystrike:fix/ferrostar-speed-run-recursion
Open

theluckystrike wants to merge 1 commit into
stadiamaps:mainfrom
theluckystrike:fix/ferrostar-speed-run-recursion

Conversation

@theluckystrike

Copy link
Copy Markdown

Fixes #964

update_user_location advanced through skipped steps by calling itself once per step (the "speed run" from #715). One update on a long route nested hundreds of frames deep and aborted with a stack overflow. This change keeps the advance logic and runs it in a loop, so the stack depth no longer grows with the number of skipped steps.

What changed

  • navigation_controller/mod.rs moves the existing body of update_user_location unchanged into update_user_location_once. It evaluates the current step, advances at most once and returns ControlFlow::Continue when the same location needs another pass.
  • update_user_location loops on that result instead of recursing. Arrival handling, waypoint trimming, deviation checks and the condition state reset are untouched.
  • The moved body keeps its indentation, so the diff of that code is the return values only (+35/-9 outside the test).

Test

test_speed_run_skipping_many_steps_uses_bounded_stack follows the recipe from the issue. It builds a 1,000 step route, starts at step 0 and sends one location on step 800 on a thread with a 256 KiB stack. It asserts that step 800 becomes current.

On main plus the test, the test aborts the process.

$ cargo test -p ferrostar --lib test_speed_run_skipping_many_steps_uses_bounded_stack
thread 'speed-run-stack' has overflowed its stack
fatal runtime error: stack overflow, aborting
(signal: 6, SIGABRT: process abort signal)

Under nextest the abort fails only that test (113 passed, 1 failed).

End to end

I ran the issue's recipe through the public API in a throwaway integration test that isn't part of the PR. It uses create_navigator with step_advance_distance_from_step(35, 20, WhileOnRoute) and StaticThreshold { 20, 30.0 } on a 1,000 step route of about 205 km. It calls get_initial_state at the route start and then one update_user_location on step 500 and on step 990.

Each update runs on a 512 KiB thread, the iOS default for secondary threads. On main both debug and release builds overflow the stack and abort. With this change the update returns with step 500 (and step 990) current, and a release build takes 255 ms and 416 ms for those two updates.

To confirm the loop changes nothing else, the same test on a large stack dumped 27 states from 9 jump targets (steps 0, 1, 2, 3, 50, 200, 997, 998 and 999, each followed by two updates at the route end). The dumps from main and from this branch are byte identical.

The per step copying of the remaining steps that the issue also describes is unchanged. The release timings above come from it. Skipping several steps in one pass is a separate design decision and stays out of this PR.

Checks

  • cargo test -p ferrostar --lib test_speed_run_skipping_many_steps_uses_bounded_stack aborts on main and passes here
  • cargo test -p ferrostar --lib 111 passed, no snapshot changes
  • cargo build --verbose, cargo nextest run --no-fail-fast (114 passed) and cargo test --doc (8 passed) in common/
  • cargo fmt --check in common/, and cargo clippy -p ferrostar --all-targets with 104 warnings on main and 104 here
  • just check-msrv (cargo hack isn't installed here)

`NavigationController::update_user_location` called itself after every
step advance to evaluate the same location against the next step (the
"speed run" behavior from stadiamaps#715). One update that lands N steps ahead of
the current step therefore nested N frames deep. On a long route that
overflows the small stacks mobile platforms give navigation threads and
aborts the process (stadiamaps#964).

I moved the existing body unchanged into `update_user_location_once`,
which evaluates the current step, advances at most once and returns
`ControlFlow::Continue` when the same location needs another pass.
`update_user_location` now loops on that result. The arrival, completion
and pass through paths return the same states as before. Only the stack
depth changes. The moved body keeps its indentation, so its diff is the
return values only.

The new test `test_speed_run_skipping_many_steps_uses_bounded_stack`
builds a 1,000 step route with the configuration from stadiamaps#964, starts at
step 0 and sends one update in the middle of step 800 on a thread with a
256 KiB stack. It asserts that step 800 becomes the current step.

Commands I ran in `common/` on macOS arm64 with rustc 1.99.0. Before the
fix (main 4e2d7f6 plus the new test) the test aborts the process.

```
$ cargo test -p ferrostar --lib test_speed_run_skipping_many_steps_uses_bounded_stack
thread 'speed-run-stack' has overflowed its stack
fatal runtime error: stack overflow, aborting
(signal: 6, SIGABRT: process abort signal)

$ cargo nextest run -p ferrostar --no-fail-fast
SIGABRT ferrostar navigation_controller::tests::test_speed_run_skipping_many_steps_uses_bounded_stack
Summary 114 tests run: 113 passed, 1 failed, 0 skipped
```

With the fix every check passes.

```
$ cargo test -p ferrostar --lib test_speed_run_skipping_many_steps_uses_bounded_stack
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 110 filtered out; finished in 1.13s

$ cargo test -p ferrostar --lib
test result: ok. 111 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
(no .snap.new files, no snapshot changes)

$ cargo build --verbose
exit 0

$ cargo nextest run --no-fail-fast
Summary 114 tests run: 114 passed, 0 skipped

$ cargo test --doc
test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

$ cargo fmt --check
exit 0

$ cargo clippy -p ferrostar --all-targets
104 warnings on main, 104 with this change
```

I also ran the recipe from stadiamaps#964 through the public API in a throwaway
integration test that isn't part of this commit. It builds a 1,000 step
route of about 205 km with `step_advance_distance_from_step(35, 20,
WhileOnRoute)` and `StaticThreshold { 20, 30.0 }`.

The test calls `get_initial_state` at the route start. Then it sends one
`update_user_location` on step 500 and one on step 990. Both run on a
512 KiB thread, the iOS default.

On main the debug and release builds both abort.

```
thread 'ios-sized-512k' has overflowed its stack
fatal runtime error: stack overflow, aborting
(signal: 6, SIGABRT: process abort signal)
```

With this change both builds return the right step.

```
debug
target step 500: current step index 500, instruction "step 500"
target step 990: current step index 990, instruction "step 990"

release
target step 500: current step index 500, update took 255 ms
target step 990: current step index 990, update took 416 ms
```

The same test on a 256 MiB stack dumped 27 states. Each run jumped to
step 0, 1, 2, 3, 50, 200, 997, 998 or 999 and then sent two updates at
the route end. The dump holds remaining steps, waypoints, deviation,
progress and snapped location. The files from main and from this change
are byte identical.

Not run are `just check-msrv` (cargo hack isn't installed here), the
wasm build and the Swift, Kotlin and web suites. This change touches
only Rust code inside the ferrostar crate and no bindings.

The per step copying of the remaining steps that stadiamaps#964 also describes is
unchanged. The release timings above come from it. Reducing it is a
separate change.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

One location update can recurse hundreds of times and crash

1 participant