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
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
YuanYuYuan
force-pushed
the
pr/4b-event-graph-reentrancy
branch
from
August 4, 2026 19:04
0c27418 to
198aa3f
Compare
YuanYuYuan
force-pushed
the
pr/5-rmw-exec-callback
branch
from
August 5, 2026 05:01
262184d to
34d0cdc
Compare
YuanYuYuan
force-pushed
the
pr/5-rmw-exec-callback
branch
from
August 5, 2026 07:47
34d0cdc to
4a7ca25
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/5-rmw-exec-callback
branch
from
August 5, 2026 16:54
4a7ca25 to
cfaa272
Compare
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.
Part of #282 — the defect class, the shared fix shape and the merge order are stated there.
Role in #282
Instance fix plus coverage, and the one that needed a different technique. Plain collect-release-call is not sufficient here; see What this PR does.
hiroz::reentrancy::TrackedMutexandinvoke_user_callback!maincrates/rmw-zenoh-rs/src/rmw.rsBase is
main. No stacking.Issue
Fixes #261. Six sites in
rmw-zenoh-rsinvoked an rclcpp executor callback with one or two mutex guards still live.rmw.rs)callback+callback_user_datarmw.rs)callback+callback_user_dataClientImpl::send_request,service.rs)callback+callback_user_datarmw_subscription_set_on_new_message_callbackcallback+unread_countrmw_service_set_on_new_request_callbackunread_countrmw_client_set_on_new_response_callbackunread_countstd::sync::Mutexis not reentrant. Installing and clearingon_new_messagecallbacks is how rclcpp executors attach and detach, so a callback that re-enters the rmw API for the same entity blocks on a lock its own thread already holds. That is a deterministic hang, not a race.The ordinary startup path reaches the worst of the six: messages arriving before the executor attaches leave a backlog, and the install path replays that backlog while holding two guards.
What this PR does
Adds
crates/rmw-zenoh-rs/src/exec_callback.rs— oneExecCallbacktype shared by subscriptions, services and clients — and rewrites all six sites to use it. Collapsing three independent mutexes per entity into one is part of the fix: with three, "notify" had to hold two guards at once to read a callback and itsuser_datatogether, and correctness rested on every site agreeing on a lock order.Dropping the guard alone is not enough, and that is what makes this instance different. In
rmw_zenoh_cpp,DataCallbackManager::trigger_callbackdispatches underevent_mutex_(rmw_zenoh_cpp/src/detail/event.cpp:93-101) andset_callbacktakes the same mutex (event.cpp:73-89). That is not only exclusion — it is a lifetime guarantee: onceset_callback(nullptr)returns, no callback is in flight, so the caller may free whatuser_datapointed at. Collect-then-dispatch discards that guarantee:sequenceDiagram participant D as delivery thread participant E as executor thread D->>D: notify_one — snapshot (callback, user_data), drop guard E->>E: set(None) — take lock, clear slot, return E->>E: destroy entity, free user_data D->>D: call callback with the stale snapshot — use-after-freeSo exclusion and the lifetime guarantee are separated:
setcannot conclude that no callback is in flight.setswaps the slot, drops the guard, then waits for registered dispatches on other threads only.The thread distinction is the whole point. A callback re-entering
seton its own thread must not wait — waiting for itself is the original deadlock. Aseton an unrelated thread must wait, because it is the one about to free the pointer. That makes the fix caller-independent rather than correct-only-if-the-caller-is-a-different-thread.A
DispatchTokenderegisters on scope exit including on unwind. Without that, an unwind through a live dispatch would leave the registration behind and every latersetwould block forever — trading a use-after-free for a permanent hang.The state mutex is a
TrackedMutexand both dispatch points go throughinvoke_user_callback!, so a reintroduction of the defect panics with the site name in debug builds and compiles to nothing in release.Evidence
cfaa2725exec_callback.rsrmw_zenoh_cppdispatches and installs under the same non-recursivestd::mutexevent.cpp:73-101,event.hpp:91Tests, by the property each one pins:
installing_a_callback_over_a_backlog_survives_reentrydelivery_notification_survives_reentry_into_setdelivery_notification_survives_reentry_into_itselfno_guard_is_live_when_the_callback_runslive_guards() == 0at both dispatch points — the tripwire is not passing vacuouslyset_waits_for_a_dispatch_in_flight_on_another_threadsetreturns strictly after the in-flight callbackset_does_not_wait_for_a_dispatch_on_its_own_threadan_unwind_through_a_dispatch_releases_its_registrationDispatchToken::dropruns on unwindEach deadlock detector runs its body on a worker thread and panics after 5 s. The timeout message names all three causes the suite distinguishes, so a failure does not misattribute itself. The detectors are written to fail against the pre-fix shape; that is established by reading them, not by executing a reverted tree while preparing this description.
Breaking changes
None to the rmw C ABI. No
extern "C"signature changes.SubscriptionImpl,ServiceImpl,ClientImpllose thecallback,callback_user_dataandunread_countfieldsArc<Mutex<..>>fields → oneexec_callback: ExecCallbacksetcan now blockrmw_*_set_on_new_*_callbackentry pointsuser_datahave finisheduser_dataafterwards safe.setwaits as long as an executor callback already running on another thread takes to return.rmw_zenoh_cpphas the same property, where the wait is onevent_mutex_instead. A callback re-entering on its own thread never waits.Both are in
exec_callback.rsand both concern the same mechanism.1.
setcan deadlock against a single re-entering callbacksetregisters its replay token at:282while holding the state guard, releases the guard at:287, and reacheswait_for_other_dispatches()at:294with that token still live. The wait skips only the current thread (:204-211).State:
callback = None,unread > 0— the ordinary startup backlog this PR exists for.set(Some(cb))swaps the slot, matchesSome(_) if unread > 0, pushes token(A), releases the guard:281-283,:287notify_one()readscallback == Some, pushes token(B), releases, invokes the callback:223-228,:244:294,:208set(None, null), hits_ => None, blocks on token(A):285,:294Neither can proceed: token(A) drops only after A's wait returns, token(B) only after B's callback returns.
Candidate fixes: evaluate the wait before registering the replay token, or exclude not-yet-dispatching replay tokens from the predicate at
:208.2. The documented residual is narrower than the real one
:77-78states the deadlock needs "two threads mutually re-entering", and:78-80names single-threaded re-entry as "the case this type makes work". The interleaving above needs exactly one re-entering thread — thread A is an ordinary install-over-a-backlog and never runs user code before blocking.So a reader who trusts the documentation concludes a reachable deadlock is safe. The
InFlightdoc at:116-120("one entry per active dispatch") is also false for token(A), which names a dispatch that has not started.Both need fixing, or the residual paragraph needs to state the real bound.
Coverage this does not have
pubsub.rsand the two subscription hunks ofrmw.rstomainreintroduces #261's highest-severity site verbatim, and all 16 tests still passset/dispatch with a backlog presentnotify_all()inDispatchToken::dropassert_completesis not applied to them--release— the guard counter is a literal0there and the macro compiles to nothingset_does_not_wait_for_a_dispatch_on_its_own_threaddelivery_notification_survives_reentry_into_setapart from the label; no revert separates themResidual deadlock, 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_data, so neither wait can safely be skipped.This is not a regression against the reference.
rmw_zenoh_cppcannot survive re-entrantsetat all:set_callbackre-locks a non-recursiveevent_mutex_on the thread that already holds it, so it deadlocks with one thread. This deadlocks only with two threads mutually re-entering. The single-threaded case — what rclcpp exercises when an executor detaches from inside a notification — is the case this PR makes work.No integration-level test. The eleven tests drive
ExecCallbackdirectly. Nothing here exercises a real rclcpp executor attaching to a live subscription.Checklist
./scripts/check-local.sh— not re-run againstcfaa2725; the 26 green CI checks on that commit are the current evidence