fix(rmw): stop invoking executor callbacks under a lock - #262
Open
YuanYuYuan wants to merge 4 commits into
Open
Conversation
YuanYuYuan
force-pushed
the
pr/4b-event-graph-reentrancy
branch
from
July 28, 2026 07:53
ac4f3ec to
6008b3f
Compare
YuanYuYuan
force-pushed
the
pr/5-rmw-exec-callback
branch
from
July 28, 2026 08:25
fb3fd0d to
06fbf58
Compare
There was a problem hiding this comment.
Pull request overview
Prevents RMW executor callbacks from running while mutex guards are held, avoiding re-entrant deadlocks.
Changes:
- Introduces a shared, lock-safe executor callback slot.
- Migrates subscription, service, and client notifications.
- Adds eight backlog and re-entrancy tests.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
src/exec_callback.rs |
Implements and tests callback dispatch. |
src/rmw.rs |
Migrates callback registration and notification. |
src/service.rs |
Updates service and client storage. |
src/pubsub.rs |
Updates subscription storage. |
src/lib.rs |
Exports the new module. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
YuanYuYuan
force-pushed
the
pr/4b-event-graph-reentrancy
branch
from
July 28, 2026 10:41
da5cb79 to
1db7794
Compare
YuanYuYuan
force-pushed
the
pr/5-rmw-exec-callback
branch
from
July 28, 2026 10:41
06fbf58 to
2b099d6
Compare
YuanYuYuan
force-pushed
the
pr/4b-event-graph-reentrancy
branch
from
July 28, 2026 12:02
1db7794 to
d15e521
Compare
YuanYuYuan
force-pushed
the
pr/5-rmw-exec-callback
branch
from
July 28, 2026 12:02
2b099d6 to
a6b0d25
Compare
YuanYuYuan
force-pushed
the
pr/4b-event-graph-reentrancy
branch
from
July 28, 2026 12:18
d15e521 to
a61d8b1
Compare
YuanYuYuan
force-pushed
the
pr/5-rmw-exec-callback
branch
from
July 28, 2026 12:18
a6b0d25 to
ebae9d8
Compare
YuanYuYuan
force-pushed
the
pr/4b-event-graph-reentrancy
branch
from
July 28, 2026 12:44
a61d8b1 to
cd4f460
Compare
YuanYuYuan
force-pushed
the
pr/5-rmw-exec-callback
branch
from
July 28, 2026 12:44
ebae9d8 to
6668e80
Compare
YuanYuYuan
force-pushed
the
pr/4b-event-graph-reentrancy
branch
from
July 28, 2026 13:09
cd4f460 to
903f62b
Compare
YuanYuYuan
force-pushed
the
pr/5-rmw-exec-callback
branch
from
July 28, 2026 13:09
6668e80 to
f8d1fec
Compare
YuanYuYuan
force-pushed
the
pr/4b-event-graph-reentrancy
branch
from
July 28, 2026 14:23
903f62b to
2126252
Compare
YuanYuYuan
force-pushed
the
pr/5-rmw-exec-callback
branch
from
July 28, 2026 14:23
f8d1fec to
239a7f1
Compare
All five remaining callback-under-lock sites in rmw-zenoh-rs invoked an rclcpp
executor callback with one or two mutex guards still live:
rmw_subscription_set_on_new_message_callback callback + unread_count
subscription delivery notifier callback + callback_user_data
service delivery notifier callback + callback_user_data
client delivery notifier (send_request) callback + callback_user_data
rmw_{service,client}_set_on_new_*_callback unread_count
`std::sync::Mutex` is not reentrant, so a callback that re-enters the rmw API
for the same entity blocks on a lock its own thread already holds. Installing
and clearing on_new_message callbacks is how rclcpp executors attach and detach,
so this is reachable, and it needs no race: the worst of the five is hit by the
ordinary startup path where messages arrive before the executor attaches and the
backlog is replayed under two guards.
Replace the per-entity trio of mutexes with one `ExecCallback` slot whose two
operations collect what they need under the lock, drop the guard, and only then
call into user code. Collapsing three mutexes into one is part of the fix rather
than tidying: with three, notification had to hold two guards at once to read a
callback and its user-data together, and correctness rested on every site
agreeing on a lock order.
The slot uses hiroz's `TrackedMutex` and dispatches through
`invoke_user_callback!`, so a reintroduction panics in debug with the site name
instead of hanging.
This crate had no coverage for this behaviour at all. Eight unit tests are added
with it, including `no_guard_is_live_when_the_callback_runs` (asserts the live
guard count is zero at the moment of dispatch),
`delivery_notification_survives_reentry_into_itself` and
`installing_a_callback_over_a_backlog_survives_reentry`, which reproduce the
self-deadlock directly. Reverting the guard-drop and keeping them turns each
into a hang.
These five were not found by the manual sweep that produced the earlier fixes in
this series. They were found by a mechanical pass over every lock acquisition
site in the workspace: enumerate acquisitions, classify each guard's lifetime,
then look inside that lifetime for a call into user code, plus a lock-order
graph for ABBA inversions.
Dropping the state guard before dispatch fixed the deadlock but removed a lifetime guarantee rmw_zenoh_cpp provides deliberately: it invokes the callback under event_mutex_, which event_set_callback also takes, so once set_callback(nullptr) returns no callback is in flight and the caller may free what user_data pointed at. Without that, a delivery thread could snapshot (callback, user_data), drop the guard, and then be overtaken by a set(None) that clears the slot and returns -- after which the entity is destroyed and the snapshot dangles. Restoring the C++ shape would reinstate the deadlock, so exclusion and lifetime are separated: each dispatch registers its thread, and set() waits for registrations on *other* threads. Scoping the wait that way is what keeps a callback re-entering set() on its own thread from waiting on itself.
A panic out of an `extern "C"` callback aborts rather than unwinding, so the previous test killed the test binary (rc=101) instead of asserting anything -- and took the other tests' results with it. The reachable unwind is Rust-side, before the boundary: `invoke_user_callback!`'s debug assertion. Raise it directly and keep asserting what matters, that the dispatch registration is released so a later set() cannot block forever.
assert_completes is now shared by tests whose blocking has three different causes; asserting the original one for all of them misdescribes two.
YuanYuYuan
force-pushed
the
pr/4b-event-graph-reentrancy
branch
from
July 30, 2026 05:33
2126252 to
0c27418
Compare
YuanYuYuan
force-pushed
the
pr/5-rmw-exec-callback
branch
from
July 30, 2026 05:33
925f256 to
262184d
Compare
This was referenced Jul 30, 2026
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.
Description
Fixes #261
All five remaining callback-under-lock sites in
rmw-zenoh-rsinvoked an rclcpp executor callback with one or two mutex guards still live:std::sync::Mutexis not reentrant, so a callback that re-enters the rmw API for the same entity blocks on a lock its own thread already holds. Installing and clearingon_new_messagecallbacks is how rclcpp executors attach and detach, so this is reachable, and it needs no race: the worst of the five is hit by the ordinary startup path where messages arrive before the executor attaches and the backlog is replayed under two guards.Depends on
pr/1-reentrancy-tripwire(forTrackedMutexandinvoke_user_callback!) and onpr/4b-event-graph-reentrancy(which also editsrmw.rs, for the event-status API change). Branched offpr/4baccordingly.Stacked PR — this PR's base is
pr/4b-event-graph-reentrancy, notmain. The diff therefore shows only this branch's own commit. Merge order ispr/1->pr/4b-> this one; GitHub retargets automatically as each parent lands.Key Changes
crates/rmw-zenoh-rs/src/exec_callback.rs(new) — oneExecCallbackslot replaces the per-entity trio of mutexes. Both of its operations collect what they need under the lock, drop the guard, and only then call into user code.Collapsing three mutexes into one is part of the fix, not tidying. With three, notification had to hold two guards at once to read a callback and its user-data together, and correctness rested on every site agreeing on a lock order. One slot removes the ordering obligation entirely.
setwaits for in-flight dispatches on other threads. Dropping the guard before dispatch is necessary but not sufficient: it also discards a lifetime guarantee the reference implementation provides on purpose.rmw_zenoh_cppinvokes the callback underevent_mutex_(DataCallbackManager::trigger_callback) andset_callbacktakes the same mutex, so onceset_callback(nullptr)returns no callback is in flight and the caller may free whatuser_datapointed at.Without that, this sequence is a use-after-free: a delivery thread snapshots
(callback, user_data)and drops the guard;set(None, ..)takes the lock, clears the slot and returns without waiting; the entity is destroyed anduser_datafreed; the delivery thread then calls through its stale snapshot.Restoring the C++ shape would reinstate the deadlock, so exclusion and lifetime are separated. Each dispatch registers the thread performing it, and
setwaits for registrations on other threads before returning. Scoping the wait that way is what keeps a callback re-enteringseton its own thread from waiting on itself — the distinction that makes this different from the original bug. Registration happens while the state guard is still held, so every dispatch holding the outgoing pointer is visible to the wait.The slot uses
hiroz::reentrancy::TrackedMutexand dispatches throughinvoke_user_callback!, so a reintroduction panics in debug with the site name instead of hanging. Neither the state guard nor the in-flight guard is held across user code; where both are taken the order is always state then in-flight.rmw.rs,service.rs,pubsub.rs,lib.rs— migrate the five sites to the slot.What fails without this
This crate had no test coverage for this behaviour at all, which is the main reason these five survived earlier sweeps. Sixteen unit tests ship with the fix (
cargo test -p rmw-zenoh-rs --lib: 16 passed, up from 5 onpr/4b).Every mechanism was proven to be load-bearing by reverting it independently and confirming the corresponding test fails. All three reverts were run in a ROS-provisioned shell against this head.
Revert 1 — remove the in-flight wait from
set(the use-after-free):That test pins a callback inside a dispatch on one thread, calls
set(None, ..)on another, and asserts by ticket order thatsetreturned strictly after the callback finished — not merely that it eventually returned.Revert 2 — wait for all threads instead of only other threads (the deadlock the scoping avoids). The run never finishes; it is killed at a 600 s timeout (
rc=124), becausesetregisters its own backlog replay and then waits for it:Note the two that hang rather than fail: they have no deadline, and they are not re-entrancy tests —
installing_a_callback_drains_the_backlogis ordinary backlog delivery. So an unconditional wait breaks the normal path too, not just the re-entrant one. That is why the wait is scoped to other threads rather than simply added.Revert 3 — make
DispatchToken::dropa no-op (the leak that would turn the fix into a hang):An unwind through a live dispatch must deregister, or the wait in
setbecomes unsatisfiable forever. Note the unwind that matters is Rust-side: the callbacks areextern "C", and a panic out of one aborts the process rather than unwinding, so a panicking executor callback never reaches anyDrop. What can unwind isinvoke_user_callback!'s own debug assertion, which fires before the call — an earlier version of this test panicked from theextern "C"callback and killed the test binary withrc=101instead of asserting anything.The original deadlock detectors, unchanged and still proven in both directions. Reverting only the guard-drop in
exec_callback.rsfails four:The three re-entry tests carry a 5 s
assert_completesdeadline rather than wedging the binary, so a revert is safe to try under CI.no_guard_is_live_when_the_callback_runsfails immediately on the assertion instead, which is what lets it catch the defect even at sites where re-entry is not attempted.cargo check -p rmw-zenoh-rs --all-targetsandcargo clippy -p rmw-zenoh-rs --all-targets -- -D warningsare clean.Breaking Changes
None to the rmw C ABI, and no signature changes.
One behavioural change worth stating:
setcan now block. It returns only once dispatches holding the outgoinguser_datahave finished, so it waits as long as an executor callback already running on another thread takes to return.rmw_zenoh_cpphas the same property — there the wait is onevent_mutex_— and it is the property that makes it safe to freeuser_dataafterwards. A callback re-entering on its own thread never waits.Residual, stated rather than hidden: two threads both dispatching this entity's callback and both re-entering
setfrom inside it will wait on each other. Each is in a live dispatch that may still touch itsuser_dataaftersetreturns, so neither wait can safely be skipped. This is not a regression against the reference:rmw_zenoh_cppcannot survive re-entrantsetat all, becauseset_callbackre-locks a non-recursiveevent_mutex_on the thread already holding it — it deadlocks with one thread. The single-threaded case, which is the one rclcpp exercises when an executor detaches from inside a notification, is exactly what this PR makes work.Notes
rmw-zenoh-rscannot be built in the pure-Rust CI shell — its build script needs ROS headers (rcutils/strdup.h) — so this PR's gates were run in a ROS-provisioned shell. Worth knowing when reviewing:cargo clippy --workspacein the pure-Rust shell silently cannot reach this crate, which is part of why it accumulated five instances of a defect class the rest of the tree had been swept for.Rebased onto
pr/4b's current tip (0c274185, itself rebased ontopr/1'sa70f7336). This branch's four commits replayed with no conflicts and its own diff is byte-identical before and after; check, clippy and all 16 tests were re-run green on the rebased head (262184da).Because this PR is based on
pr/4brather thanmain,ci.ymldoes not run on it (3 checks, not 27), so none of the above has executed on a GitHub runner. Re-verify once the base moves tomain— and note this branch is two levels deep, so it needs replaying afterpr/4bis replayed.Checklist
./scripts/check-local.shsuccessfully