fix(event,graph): run event callbacks outside the locks they live under - #260
Conversation
ad3a4d7 to
9bc6dfd
Compare
ac4f3ec to
6008b3f
Compare
There was a problem hiding this comment.
Pull request overview
Moves graph and event callbacks outside mutex guards to prevent re-entrant deadlocks and lock-order inversion.
Changes:
- Snapshots callbacks before invocation and introduces shared event-status helpers.
- Releases graph-data locks before graph notifications.
- Adds re-entrancy regression tests.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
crates/hiroz/src/event.rs |
Refactors callback storage, locking, and dispatch. |
crates/hiroz/src/graph.rs |
Narrows graph-data guard lifetimes. |
crates/rmw-zenoh-rs/src/rmw.rs |
Uses shared event-status helpers. |
crates/rmw-zenoh-rs/src/context.rs |
Constructs the trigger callback with Arc. |
crates/hiroz-tests/tests/reentrant_graph_event.rs |
Tests graph callback re-entrancy. |
crates/hiroz-tests/tests/reentrant_event_status.rs |
Tests event-status callback re-entrancy. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (6)
crates/hiroz/src/event.rs:81
set_callbackstill invokes the backlog callback from a&mut selfmethod. A public caller using the normal shared shape (manager.lock().unwrap().set_callback(...)) therefore keeps the outerMutex<EventsManager>guard alive here, and a callback that callstake_eventdeterministically deadlocks. Theinstall_callbacksplit only avoids this throughRmEventHandle; this wrapper needs the same shared-manager treatment, or it must be restricted/deprecated for exclusively owned managers. This invocation should also use the repository'sinvoke_user_callback!tripwire.
callback(unread_count);
crates/hiroz/src/event.rs:457
- The shared-manager path correctly drops its mutex, but this is still invocation of the user callback and must use
invoke_user_callback!under the rule inreentrancy.rs:43-49. Calling it directly leaves this newly introduced dispatch path outside the debug-time guard-lifetime detector.
if let Some(callback) = callback {
callback(change);
}
crates/hiroz/src/event.rs:508
- This backlog notification is user code, so invoking it directly violates the explicit convention in
reentrancy.rs:43-49that every user-code call site usesinvoke_user_callback!. Wrapping it preserves the lock-release fix while allowing debug builds to catch any tracked guard retained by future callers.
if unread_count != 0 {
callback(unread_count);
}
crates/hiroz/src/event.rs:347
GraphGuardCondition::triggeris an externally implemented callback (and the surrounding comment explicitly says it may re-enter the manager), but this dispatch bypassesinvoke_user_callback!. Perreentrancy.rs:43-49, wrap this call so debug builds detect if any tracked hiroz guard remains live at this callback boundary.
for gc in guard_conditions {
gc.trigger();
}
crates/hiroz/src/event.rs:203
- The PR's Breaking Changes section says
GraphGuardConditionTriggermerely changes fromBoxtoArc, but this diff actually removes that public alias andset_guard_condition_trigger, introduces a new trait, and changes registration from a raw pointer toArc<dyn GraphGuardCondition>. Downstream migration is therefore not the stated one-tokenBox::new→Arc::newupdate; update the compatibility design or document the full public API break and migration.
pub trait GraphGuardCondition: Send + Sync {
/// Wake whatever is waiting on this guard condition.
///
/// Called with no hiroz lock held, possibly concurrently, and possibly
/// after the corresponding C handle has been destroyed.
fn trigger(&self);
crates/hiroz/src/event.rs:123
- This is user callback dispatch, but it bypasses the mandatory re-entrancy tripwire.
reentrancy.rs:43-49requires every user-code invocation to go throughinvoke_user_callback!; without it, this public wrapper can silently invoke user code while another tracked hiroz guard is live.
This issue also appears in the following locations of the same file:
- line 345
- line 455
- line 506
if let Some(callback) =
self.record_event_status_with_policy(event_type, change, policy_kind)
{
callback(change);
}
da5cb79 to
1db7794
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (7)
crates/hiroz/src/event.rs:203
- This public trait replaces
GraphGuardConditionTriggerand the diff also removesset_guard_condition_triggerand changes both registration signatures. The PR description instead says the alias merely changes fromBoxtoArcand lists only two alias-shape breaks. Please update the breaking-change and migration notes (or preserve compatibility) so downstream impact is accurately disclosed.
pub trait GraphGuardCondition: Send + Sync {
/// Wake whatever is waiting on this guard condition.
///
/// Called with no hiroz lock held, possibly concurrently, and possibly
/// after the corresponding C handle has been destroyed.
fn trigger(&self);
crates/hiroz/src/event.rs:122
- This callback invocation bypasses the mandatory
invoke_user_callback!check documented incrates/hiroz/src/reentrancy.rs:43-49. The direct-manager path can still be reached while another tracked hiroz guard is live, so use the tripwire here as well.
callback(change);
crates/hiroz/src/event.rs:346
GraphGuardConditionis a public, externally implementable trait, sotrigger()is also an invocation of user-supplied code. Per the invariant incrates/hiroz/src/reentrancy.rs:43-49, dispatch it throughinvoke_user_callback!just like the endpoint callbacks below.
gc.trigger();
crates/hiroz/src/event.rs:456
- The shared-manager callback is user code but this new dispatch bypasses the
invoke_user_callback!invariant incrates/hiroz/src/reentrancy.rs:43-49. Use the macro after dropping the manager guard so upstream tracked-lock regressions are detected.
callback(change);
crates/hiroz/src/event.rs:507
- This backlog dispatch is another user callback site that skips the required tripwire (
crates/hiroz/src/reentrancy.rs:43-49). Wrapping it preserves the lock-free behavior while detecting callers that still hold a tracked hiroz guard.
callback(unread_count);
crates/hiroz/src/event.rs:245
- Releasing
event_callbacksbefore insertingentity_topicsmakes registration observable in a partial state:trigger_eventcan already invoke the new callback, while a concurrenttrigger_graph_changecan miss it because its topic is not present yet. Keep the callbacks guard until the topic insertion completes (the trigger path already takes these locks in the same order) so registration has one atomic visibility point.
{
let mut callbacks = self.event_callbacks.lock().unwrap();
let entity_callbacks = callbacks.entry(entity_gid).or_default();
entity_callbacks.insert(event_type, Arc::new(callback));
}
crates/hiroz/src/event.rs:81
- This invokes externally supplied callback code directly, bypassing the repository's required re-entrancy tripwire (
crates/hiroz/src/reentrancy.rs:43-49). Route it throughinvoke_user_callback!so a callback reached while any tracked guard is live fails diagnostically instead of hanging.
This issue also appears in the following locations of the same file:
- line 122
- line 346
- line 456
- line 507
callback(unread_count);
cd4f460 to
903f62b
Compare
21c96f5 to
69326f0
Compare
2126252 to
0c27418
Compare
2db0ccd to
cf4911a
Compare
0c27418 to
198aa3f
Compare
e33cf29 to
cbabd82
Compare
Two public-API-reachable deadlocks in the graph/event machinery, both the same
class: a user callback invoked while the registry it is registered in is still
locked.
Graph and endpoint events. `GraphEventManager` invoked registered callbacks
while holding their registries: `trigger_event_with_policy` called under the
`event_callbacks` guard, `trigger_graph_change` held both `event_callbacks` and
`entity_topics` for the whole notification loop, and the guard-condition sweep
held `trigger_guard_condition` plus `graph_guard_conditions`. The hot path into
`trigger_graph_change` is the liveliness subscriber declared in
`Graph::new_with_pattern`, which held the `GraphData` mutex across the call — so
on every liveliness token these callbacks ran with three or four non-reentrant
locks held, and they are user code that the rmw layer hands straight to an
rclcpp executor. Any callback that re-entered hiroz (counting publishers,
unregistering an entity, registering a new one) self-deadlocked on the thread
already holding the guard.
That nesting is also a lock-order inversion, not only a re-entrancy hazard: the
liveliness path takes `GraphData` then `event_callbacks`, while
`add_local_entity` and the rmw-side callers reach `event_callbacks` without
`GraphData`, so two threads could interleave into a classic ABBA with no
callback involved at all.
Fixed the way zenoh core does it in `resolve_put`: collect under the lock, drop
every guard, then invoke. `EventCallback` and `GraphGuardConditionTrigger`
become `Arc` instead of `Box` so they can be cloned out cheaply. The liveliness
subscriber scopes its `GraphData` guard so it is released before any trigger,
matching what `add_local_entity` already did.
Event status updates. `EventsManager` lives behind an `Arc<Mutex<..>>` shared
with `RmEventHandle`. `update_event_status` takes `&mut self`, so every caller
necessarily holds that outer mutex — and the method fires the registered
callback from inside it. Only the inner `event_mutex` was released first. The
callback is user code the rmw layer hands to an rclcpp executor, and the first
thing such a callback typically does is ask the handle that fired for the status
behind it (`rmw_take_event`), which locks that same mutex on the same thread.
Self-deadlock, no race required; all eight call sites in `rmw-zenoh-rs` had this
shape.
Collect-then-invoke cannot be done from inside a `&mut self` method, which does
not own the guard and so cannot drop it. Split it: `record_event_status_with_policy`
records and returns the callback owed a notification without calling it, and two
free functions, `update_shared_event_status[_with_policy]`, take the
`Mutex<EventsManager>` directly so they can drop the guard before invoking.
Those are now the entry point for every holder of a shared manager;
`update_event_status[_with_policy]` stay as thin wrappers for callers that own
the manager outright.
The four registries become `TrackedMutex` and both trigger paths dispatch
through `invoke_user_callback!`, so a reintroduction panics in debug naming the
site rather than hanging.
Behavioural notes from collect-then-invoke. `trigger_graph_change` now
snapshots the notify list, so a callback that registers or unregisters during a
sweep no longer affects that same sweep — previously it deadlocked, so no
working behaviour changes. `EventsManager::set_callback` fires its backlog
notification after installing the callback rather than before, again only
observable to a re-entrant callback that used to deadlock. Event ordering
relative to graph mutation is preserved deliberately: on a liveliness PUT the
entity is inserted before the event fires, on a DELETE the event fires before
removal, exactly as before — only the lock is no longer held across the gap, so
the mutation and the notification are no longer atomic with respect to each
other.
Detector evidence, both directions. Five deadline-guarded tests. With the fix
reverted and the tests kept, all five fail:
event_callback_unregistering_does_not_deadlock 30s deadline - deadlock
graph_change_callback_registering_does_not_deadlock 30s deadline - deadlock
graph_change_callback_querying_the_graph_does_not_deadlock
the re-entrant graph query returned 0 publishers
event_callback_taking_its_own_status_does_not_deadlock 30s deadline - deadlock
event_callback_reinstalling_itself_does_not_deadlock 30s deadline - deadlock
With the fix, all five pass.
`crates/rmw-zenoh-rs/src/context.rs` is compiler-verified here for the first
time: the `Box::new` -> `Arc::new` token the `EventCallback` alias change forces
had never been compiled, because the crate's build script needs ROS headers.
Restoring `Box::new` fails with E0308 at context.rs:100, confirming the file is
genuinely reached and the token is both necessary and correct.
…inters Moving the guard-condition trigger out of the registry lock was necessary -- the trigger is rmw code that re-enters hiroz -- but it turned a deadlock into a use-after-free. `trigger_graph_change` snapshotted `Vec<usize>` of raw pointers, released the lock, then dereferenced them. The lock was the only thing serialising that against teardown: `rmw_destroy_node` calls `unregister_graph_guard_condition` and then immediately `rmw_destroy_guard_condition`, which frees the handle. Previously `unregister` blocked until triggering finished, so the free could not land mid-trigger. Afterwards it could, and the trigger wrote through freed memory. Concurrent triggers also aliased `&mut GuardConditionImpl`. Make registrations owned. hiroz gains a `GraphGuardCondition` trait and stores `Arc<dyn GraphGuardCondition>`; the snapshot clones `Arc`s, so an in-flight trigger keeps its target alive no matter what teardown does. `unregister` is by `Arc::ptr_eq` and no longer implies "no trigger is running" -- it does not need to, because the survivor keeps the object alive. rmw-zenoh-rs splits `GuardConditionImpl` into a C-side handle and an `Arc<GuardConditionState>` holding the notifier and an `AtomicBool`. The node registers a clone of that state and keeps one itself for unregistration. `triggered` becomes atomic because triggering no longer happens under any lock. The process-wide `set_guard_condition_trigger` indirection is gone -- each registration now carries its own behaviour -- so `GraphGuardConditionTrigger` is removed. Covered by a unit test pinning the ownership contract: the registry keeps the value alive after the registrant drops its handle, and releases it on unregister. That property is what makes the destroy-during-trigger race harmless, and unlike the race itself it is deterministic to assert.
…assertion **Soundness.** `rmw_trigger_guard_condition` took `&mut GuardConditionImpl` while `rmw_wait` holds `&GuardConditionImpl` to the same object. Moving `triggered` behind an `AtomicBool` defined the data race but said nothing about the aliasing: handing out a `&mut` to an object another thread holds a `&` to is undefined behaviour whatever the field types are. All mutation already goes through atomics in `GuardConditionState`, so `trigger` and `reset` now take `&self`, the FFI entry point borrows via `borrow_data`, and the wait-set accessor takes a shared reference. **Test strength.** `reentrant_graph_event.rs` asserted the re-entrant query saw `>= 1` publisher -- satisfiable by `local_pub`, which exists for the whole scenario. A regression invoking the callback *before* inserting the remote entity would still return 1 and pass, so the assertion did not depend on the ordering it claimed to verify. It now requires both. **Lock scope.** `EventsManager::set_callback` fires the backlog callback while the caller's outer `Mutex<EventsManager>` guard is live -- the "outside the lock" it releases is only the inner `event_mutex`. The docs now say so, and `set_shared_callback` is the safe entry point for `&Mutex<EventsManager>` holders, mirroring `update_shared_event_status`. No caller needed migrating: `RmEventHandle::set_callback` already collects under the guard and fires after releasing, and the remaining call sites own their manager outright.
No behaviour change. The pinned rustfmt in the CI pre-commit gate keeps this field declaration on one line; the local `cargo fmt --all --check` does not.
wait_set read is_ready() then reset(), two ops: a trigger landing between them was swallowed, so the waiter reported that wake and the next rmw_wait blocked until timeout. take_triggered() is a single swap. set_shared_callback had zero callers and its doc block had absorbed update_shared_event_status's, leaving that function undocumented. Removed; RmEventHandle::set_callback already does the same collect-release-fire inline. Documents the one hazard the fix shape introduces: releasing the guard before invoking also releases the mutual exclusion that serialised this against rmw_event_set_callback(.., null), so a concurrent detach can free user_data between the clone and the call.
The event callback is stored as `Fn(i32) + Send + Sync`. A raw pointer is neither, so capturing `user_data` directly does not compile; the `as usize` round-trip made it compile. That silenced the auto-trait check that was correctly flagging a pointer crossing threads, and destroyed the pointer's provenance, so Miri and provenance-aware tooling stopped seeing the access. Capture it in a newtype whose `Send`/`Sync` impls state the assertion once, and record at the call site that pointee validity is explicitly not among the things they assert. No behaviour change; the known detach race is unaffected and still tracked.
cbabd82 to
83b3e21
Compare
The production diff was 47% comment, and event.rs restated the same rule three times: that `&mut self` proves the caller holds the outer mutex, so invoking a callback from such a method self-deadlocks. State it once on `EventsManager` and reference it. Cut the known-hazard block down to the trap and a pointer to #287 rather than reproducing the issue, and drop the restatements in the guard-condition docs. Also corrects reentrant_event_status.rs's module doc, which undersold its own tests as "not A/B detectors". True of a wholesale revert -- the file stops compiling -- but they do detect the regression that can still happen: reinstating the callout inside update_shared_event_status.
RMW_EVENT_MESSAGE_LOST was fully plumbed and never raised: the enum, the rmw_event_type mapping, the rmw_message_lost_status_t fill-in and the Attachment::sequence_number on the wire all existed, but nothing emitted it. A subscriber asking for the event got a callback that never fired and a status permanently zero (#292). MessageLossTracker holds the last sequence seen per publisher GID and raises the event when an arrival skips past one. It is the only place hiroz trailed rmw_zenoh_cpp on event coverage. Deliberately not counted: a subscriber dropping its own oldest queued sample at the history depth. That sample arrived and updated the baseline, so it produces no gap -- and upstream draws the line in the same place, logging depth-drops at debug and raising the event only for gaps. Raises via update_shared_event_status, so the callout happens with no lock held (#259/#260); the per-GID map has its own lock and is never held across it. Depends on #260 for that entry point -- it does not exist on main.
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 widest one. It converts the graph and event registry locks. It is also why the debug-time monitor counts guards rather than setting a flag: the liveliness path runs with three tracked-or-untracked locks held, and a single bit cannot be cleared soundly on release.
Consumes the types added by #255 (
TrackedMutex,invoke_user_callback!), which is merged. Targetsmain; the merge base isaf00f1ed, the current tip ofmain.Issue
Fixes #259 — three failures spanning two defect classes.
GraphEventManagerinvoked registered callbacks while holding the registries they live in. The hot path is the liveliness subscriber inGraph::new_with_pattern, which also held theGraphDatamutex — so three locks (GraphData,event_callbacks,entity_topics) on every liveliness token, around user code the rmw layer hands to an rclcpp executorGraphDataacross the acquisition ofevent_callbacks, so that order constrained every other path;add_local_entityand the rmw callers reachevent_callbackswithoutGraphData. #259 classifies this as an ABBA inversionEventsManageris shared asArc<Mutex<..>>, so&mut selfproves the caller holds it — andupdate_event_statusfired the callback from there. All eight call sites incrates/rmw-zenoh-rs/src/rmw.rshad this shapeFailure 2 is not covered by the "no callout under a lock" property. A guard count cannot see lock order.
What this PR does
trigger_event_with_policy,trigger_graph_changeand the guard-condition sweep collect under the lock, drop every guard, then notifyGraph::new_with_patternstops holdingGraphDataacrosstrigger_graph_changeGraphData-then-event_callbacksleg of failure 2record_event_status_with_policyreturns the callback (#[must_use]);update_shared_event_status[_with_policy]becomes the entry point holders of the shared manager useevent_callbacks,entity_topicsandgraph_guard_conditionsbecomeTrackedMutex; the twoGraphEventManagercallouts route throughinvoke_user_callback!GuardConditionState::take_triggered(oneswap) replaces read-then-reset inrmw_waittrigger()landing between the two atomic ops was swallowed — that wake was reported and the nextrmw_waitblocked until timeoutGraphGuardConditionTriggerremoved; registrations becomeArc<dyn GraphGuardCondition>, andNodeImplkeeps the same allocation so teardown can unregister itrmw_destroy_nodeunregisters and frees while a trigger is in flightuser_datapointer stops being laundered throughusize; it moves in a newtype with explicitunsafe impl Send/Syncas usizeround-trip silenced theSend/Synccheck that was flagging a pointer crossing threads, and destroyed the pointer's provenanceChange 5 is not a re-entrancy defect — it was found while reading the same file. Its symptom is a latency spike rather than a hang, which is how it survived.
Kept whole deliberately. Splitting on the crate boundary was considered and rejected: the
rmw-zenoh-rshalf implements a trait the hiroz half defines, so an rmw-only PR would not compile until the hiroz-only PR merged — another stacked PR, which is what this series exists to remove.Evidence
Six tests are added: five deadlock scenarios (three in
reentrant_graph_event.rs, two inreentrant_event_status.rs) and one unit test inevent.rspinning that the guard-condition registry owns its registrations.e285962c(current tip)event.rsandgraph.rs, keep the threereentrant_graph_eventtestscbabd82c(earlier revision of this branch, before the rebase ontoaf00f1ed)reentrant_event_statustestsupdate_shared_event_status_with_policyand re-runThe third row is why the event-status pair is necessary rather than a passenger: it does not detect the history (a wholesale revert stops the file compiling) but it does detect the property. Reinstate the callout and both tests hang. That is the regression still reachable, since the old entry point is gone.
The reinstated-callout run fails on the harness's timeout arm, not its panic arm, so it is classified as a deadlock rather than an assertion failure.
e285962c. The fix-present row was measured at the current tip.Breaking changes
Three public items in
hiroz, all source-breaking.rmw-zenoh-rsis consumed over the C ABI, so its changes are not part of this list.pub type EventCallbackBox<dyn Fn(i32) + Send + Sync>→Arc<…>Box::new→Arc::newpub type GraphGuardConditionTriggerregister_graph_guard_condition/unregister_graph_guard_conditionnow takeArc<dyn GraphGuardCondition>impl GraphGuardConditionGraphEventManager::set_guard_condition_triggerNeither of the first two is a rename:
Boxcannot be cloned, so it cannot be lifted out of a guard — and lifting the callback out is the fix.Additive, not breaking:
EventsManager::install_callbackandEventsManager::record_event_status_with_policyare new;update_shared_event_status[_with_policy]are new free functions.Coverage this does not have
GraphDatais untrackedparking_lot::Mutex. Reintroduce the deleted guard at the top of the liveliness callback andassert_no_guards_heldstill reports 0. That hot path is caught by exactly one test —graph_change_callback_querying_the_graph_does_not_deadlock— which needs a router, is#[serial], and polls for propagationevent.rsbypassinvoke_user_callback!EventsManager::set_callback(backlog),EventsManager::update_event_status_with_policy,gc.trigger()insidetrigger_graph_change,update_shared_event_status_with_policy,RmEventHandle::set_callback(backlog). Only the twoGraphEventManagerregistry callouts are routedtake_triggeredto the racy read-then-reset leaves the suite green: 93 integration tests and 288 library unit tests all passed. The lost-wake bug is guarded only by the reading that found itAn event callback can fire after
rmw_event_set_callback(event, nullptr, nullptr)returns, and invoke a staleuser_data. Tracked in #287 with the design settled.Arcout and releases the manager lock. Thread B (executor teardown) callsset_callback(nullptr), which returns; rclcpp frees the pointee. Thread A then calls the closure.Mutex<EventsManager>, whichRmEventHandle::set_callbackalso takes, so a detach could not return mid-invocation. That exclusion was accidental but load-bearing.Arcdoes not helpstd::stop_callback's destructor contractThis does not block merging, but it is a real trade, and it is not a gap shared with upstream.
rmw_zenoh_cppdoes not have this seam:EventsManager::event_set_callback(rmw_zenoh_cpp/src/detail/event.cpp:105) andEventsManager::trigger_event_callback(:133) both takeevent_mutex_(:118,:143) and invoke the user callback while holding it (:126,:146). Upstream's detach therefore cannot return mid-invocation.So upstream takes the other horn: it keeps the lock, has no use-after-free window, and does have the deadlock this PR removes. Closing #287 is hardening beyond what any peer implementation provides, not catching up to one.
How upstream compares
Read from
rmw_zenoh_cppsource, not inferred. Paths below are relative tormw_zenoh_cpp/src/detail/.parse_putunlocksgraph_mutex_atgraph_cache.cpp:428before invoking, with thegraph_mutex_ → discovery_mutex_order documented at:205parse_putholdsgraph_mutex_(graph_cache.cpp:367) throughhandle_matched_events_for_put(:180) intoupdate_event_counters, which locksevents_mutex_(:1496) and invokes the callback at:1528event_set_callbacktakes (event.cpp:118,:126,:143,:146)graph_cache.cpp:210-213).Checklist
&mut selfrule is stated once onEventsManagerand referenced, rather than restated at three call sites./scripts/check-local.shnot re-run since the rebase; CI covers it