fix: don't clear wait_set triggered flag without holding condition_mutex - #1
Merged
karmanyaahm merged 1 commit intoAug 11, 2026
Conversation
Author
|
This is vibecoded |
Author
|
just yolo for now |
check_and_attach_condition() ends by writing wait_set_data->triggered = false based on a comment asserting that rmw_wait() holds condition_mutex across the call. That precondition was removed by ros2#1005 (backported to humble as ros2#1015), which deliberately drops the lock around check_and_attach_condition() to avoid the ABBA deadlock in ros2#998. The write is now unsynchronized and races with the notifier paths, which set triggered = true while holding condition_mutex. If a message is delivered after its entity has been attached but before check_and_attach_condition() returns, the trailing write clears the flag, rmw_wait() then evaluates its predicate as false and blocks on the condition variable with data already queued. The reset is redundant in any case: rmw_wait() sets triggered = false under condition_mutex immediately before calling this function. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
karmanyaahm
force-pushed
the
fix/rmw-wait-lost-wakeup-unlocked-triggered-reset
branch
from
August 11, 2026 06:02
b9f6264 to
473acdc
Compare
karmanyaahm
pushed a commit
that referenced
this pull request
Aug 11, 2026
Release the unsynchronized wait_set triggered-reset fix (#1). Bumps package.xml and adds the matching CHANGELOG.rst entry, which is what actually sets the version: bloom builds debian/changelog from the changelog entries, so a package.xml bump on its own leaves the deb at the old version. Downstream (innate-packages) publishes this as ros-humble-innate-rmw-zenoh-cpp and its previous build went out as 0.1.9-1jammy, so apt only offers the fix once the version moves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAZCSDuuqDnBJb2XrE3jLt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
check_and_attach_condition()clearswait_set_data->triggeredwithout holdingcondition_mutex, which can swallow a wakeup and parkrmw_wait()forever with messages already queued. This deletes that write.Observed on ROS 2 Humble,
rmw_zenoh_cpp0.1.9 (arm64, Ubuntu 22.04, Jetson), against a long-lived rosbridge-style node subscribed to many topics. 0.1.9 already contains the ros2#1015 deadlock fix — this is a separate race introduced by that fix, not the bug it fixed.Root cause
rmw_wait()since ros2#1005 / ros2#1015:{ // reset the trigger prior to attaching any entities std::unique_lock<std::mutex> lock(wait_set_data->condition_mutex); wait_set_data->triggered = false; } { // We explicitly do not lock the condition_mutex here // ... // Note taking the mutex here leads to a deadlock. bool skip_wait = check_and_attach_condition(...); if (!skip_wait) { std::unique_lock<std::mutex> lock(wait_set_data->condition_mutex); wait_set_data->condition_variable.wait( lock, [wait_set_data]() { return wait_set_data->triggered; }); } }But
check_and_attach_condition()still ends with:That comment's precondition is no longer true. ros2#1005 deliberately dropped the lock around this call to break the ABBA cycle in ros2#998, but this trailing write was left behind. It now races with the notifier paths, e.g.
SubscriptionData::add_new_message():Interleaving:
triggered = false(under lock)check_and_attach_condition()attaches wait set to sub Striggered = true(under lock),notify_one()— no waiter yettriggered = false(no lock) — clobbers step 3wait(pred)→ predicate false → blocks with data queuedThe race window is the entire attach loop, so the probability scales with the number of entities in the wait set and the message rate — consistent with ros2#998's report that likelihood rises with topic count and rate.
The write is also redundant:
rmw_wait()already performs the same reset undercondition_muteximmediately before calling this function.Evidence
Thread stacks from the wedged process (16 threads, captured with gdb at the moment of the stall):
One executor thread parked in
rmw_wait; every other executor thread queued behindwait_mutex_, whichMultiThreadedExecutor::run()holds acrossget_next_executable(). All callback dispatch stops process-wide.While wedged:
ros2 topic hz /tfreported a steady 99.0 Hz from another process throughout;RUST_LOG=zenoh=debug,zenoh_transport=traceoutput went completely silent for the stalled session while other nodes kept receiving;Reproduction
A rosbridge-style bridge (C++,
MultiThreadedExecutor) with a websocket client subscribed to/tfat ~99 Hz alongside the node's other subscriptions. A single subscriber on an otherwise idle graph did not reproduce it — the wait set has to hold enough entities to widen the window.Before / after
Same binary, same host, same workload; the only delta is this deletion. Every unpatched run wedged permanently and never recovered.
Risk
Removing the reset can at worst produce a spurious wakeup —
rmw_wait()returning with nothing ready — which the existing per-entity readiness checks after the wait already handle, and which callers must tolerate regardless. That failure mode is strictly more benign than an unrecoverable hang.Notes
rolling,humble,jazzyandkiltedas of this writing, so the same deletion should apply upstream.humble@0307698, which is 0.1.9 plus When SHM enabled, enable transport_optimization (backport #1020) ros2/rmw_zenoh#1024 and return early when unable to find any topic endpoints. (backport #1017) ros2/rmw_zenoh#1025.Reproduction scripts
Everything used to find, confirm and measure this. All are standalone (
pip install websockets) and talk plain rosbridge protocol to the bridge under test.tf_wedge_probe.py— detects the wedge and proves the socket is still alivetf_wedge_capture.py— same, but shells out to gdb the instant it stalls (this produced the stacks above)tf_scope_probe.py— establishes the stall is server-wide, not per-connectiontf_pub.py— minimal 100 Hz /tf publisher for the isolated control (which did not reproduce)