Skip to content

fix: prevent stale until_promise_resolved watchers from halting later spins - #716

Open
fayezzouari wants to merge 4 commits into
ros2-rust:mainfrom
fayezzouari:fix/stale-spin-promise-watcher
Open

fayezzouari wants to merge 4 commits into
ros2-rust:mainfrom
fayezzouari:fix/stale-spin-promise-watcher

Conversation

@fayezzouari

Copy link
Copy Markdown

Fixes #715

If a spin with until_promise_resolved ends for another reason (e.g. a timeout), the watcher task keeps the executor-wide halt_spinning flag. When the promise resolves later, it halts the next spin, which returns immediately. rclcpp and rclpy do not behave this way.

This PR gives each spin its own halt flag. ExecutorCommands::halt_spinning() still stops the current spin, and a leftover watcher can only affect the spin that created it. There are no public API changes.

Tests:

  • test_stale_promise_does_not_halt_later_spin: on main, the second spin returns after ~22 µs instead of 500 ms
  • test_halt_spinning_stops_current_spin

This pull request was assisted by Claude:claude-opus-5-5.

… spins

When a spin with `until_promise_resolved` ended for another reason (e.g. a
timeout), the task watching the promise stayed alive and kept a handle to the
executor-wide `halt_spinning` flag. Once the promise resolved, it halted
whichever spin was running at that time, or the next one to start.

Give each spin its own halt flag. `ExecutorCommands::halt_spinning()` sets the
flag of the current spin, and a leftover watcher can only set the flag of the
spin that created it.

Assisted-by: Claude:claude-opus-5-5
Comment thread rclrs/src/executor.rs Outdated
async_worker_commands: Arc<WorkerCommands>,
halt_spinning: Arc<AtomicBool>,
/// Halt flag of the most recent spin.
current_halt: Mutex<Arc<AtomicBool>>,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn't need to use a mutex around an AtomicBool. You should be able to remove the Mutex<_> and then we won't need to lock anything in order to store or load.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh sorry, I missed the fact that we change which Arc is being held by the field. Please ignore the previous comment.

Revert the ExecutorCommands changes so the shared halt_spinning flag no longer
needs a Mutex. Instead, each BasicExecutorRuntime::spin creates a token, and the
until_promise_resolved watcher holds a Weak reference to it. When the promise
resolves, the watcher only halts spinning if its own spin is still alive. Async
tasks are only polled inside spin(), so this check cannot race with a spin
ending.

Assisted-by: Claude:claude-opus-5-5
Comment thread rclrs/src/executor.rs Outdated

fn make_spin_conditions(&self, options: SpinOptions) -> SpinConditions {
self.commands.halt_spinning.store(false, Ordering::Release);
let halt_spinning = Arc::new(AtomicBool::new(false));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know a bool isn't much of a memory footprint to be concerned about, but I worry about having a mandatory heap allocation each time that spin conditions are created. That takes us one step away from potentially supporting real time execution (not that we support it yet...).

I appreciate the elegance of this solution, and we definitely want to fix this problem, but I'd like to put a little thought into whether we can fix this effectively without an allocation. Perhaps we can introduce a second atomic variable 🤔

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it is honestly fair to think of it that way. I switched to another method, now the executor keeps a spin counter. Each time spin() starts, the counter goes up by one: spin 1, spin 2, spin 3, and so on. When a watcher is created, it remembers the current number, for example "I belong to spin 1". When the promise resolves, the watcher checks the counter first:

  • If it still says 1, its spin is still running, so it stops the spin as intended.
  • If it says 2 or more, its spin is already over, so it does nothing.

… spin

Revert the per-spin halt flag in ExecutorCommands, which allocated on every
spin. BasicExecutorRuntime now owns a spin counter that is allocated once. The
until_promise_resolved watcher records which spin created it and only halts
spinning if that spin is still running.

Assisted-by: Claude:claude-opus-5-5
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.

until_promise_resolved watcher outlives its spin() and halts a later, unrelated spin

2 participants