fix(event,graph): run event callbacks outside the locks they live under - #260
fix(event,graph): run event callbacks outside the locks they live under#260YuanYuYuan wants to merge 4 commits into
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
903f62b to
2126252
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.
2126252 to
0c27418
Compare
Description
Fixes #259
GraphEventManagerinvoked registered callbacks while holding the registries they are registered in, andEventsManager::update_event_statusfired its callback from a&mut selfmethod whose caller necessarily holds the shared outer mutex. The hot path in is the liveliness subscriber, which held theGraphDatamutex across the call — so on every liveliness token these callbacks ran with three or four non-reentrant locks held, and they are user code the rmw layer hands straight to an rclcpp executor.That nesting is also a lock-order inversion, not only a re-entrancy hazard: the liveliness path takes
GraphDatathenevent_callbacks, whileadd_local_entityand the rmw-side callers reachevent_callbackswithoutGraphData, so two threads could interleave into a classic ABBA with no callback involved at all.Depends on
pr/1-reentrancy-tripwire, which suppliesTrackedMutexandinvoke_user_callback!. Branched off it accordingly.Stacked PR — this PR's base is
pr/1-reentrancy-tripwire, notmain. The diff therefore shows only this branch's own commit. Mergepr/1first; GitHub will retarget this one tomainautomatically when it does.Key Changes
crates/hiroz/src/event.rs— collect under the lock, drop every guard, then invoke, the way zenoh core does it inSession::resolve_put.EventCallbackbecomesArcinstead ofBoxso it can be cloned out cheaply.crates/hiroz/src/graph.rs— the liveliness subscriber scopes itsGraphDataguard so it is released before any trigger, matching whatadd_local_entityalready did. This is what closes the ABBA leg.Event status needed a split, not a scope change. Collect-then-invoke cannot be done from inside a
&mut selfmethod, which does not own the guard and so cannot drop it.record_event_status_with_policynow records and returns the callback owed a notification without calling it, and two free functions,update_shared_event_status[_with_policy], take theMutex<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 unit tests), documented as such.Guard conditions become an owned trait object, because dropping the lock creates a lifetime problem the old design could not express. Once the registry lock is released before triggering, a registered raw
*mut c_voidcan be freed by a concurrentrmw_destroy_nodein the window between snapshot and call — the trigger would then write through dangling memory. So theGraphGuardConditionTriggeralias is removed and replaced by aGraphGuardConditiontrait; the registry holdsArc<dyn GraphGuardCondition>and shared ownership keeps the target alive for the duration of any in-flight trigger. See Breaking Changes.crates/rmw-zenoh-rs/— the rmw half of that redesign, which is most of this diff's surface outsidehiroz:guard_condition.rssplits the triggerable state (GuardConditionState) from the C handle, behind anArcso it can outlivermw_destroy_guard_condition. It implementshiroz::event::GraphGuardCondition.triggeredbecomes anAtomicBool: triggering no longer happens under any lock, so a graph-event thread can set it whilermw_waitreads it.GuardConditionImpl's methods take&self, not&mut self, for the same reason.node.rsregisters the shared state, not the C pointer.update_event_status*call sites inrmw.rsmove toupdate_shared_event_status*.The four registries become
TrackedMutexand both trigger paths dispatch throughinvoke_user_callback!, so a reintroduction panics in debug naming the site rather than hanging.Behavioural notes from collect-then-invoke, all only observable to a callback that previously deadlocked:
trigger_graph_changenow snapshots the notify list, so a callback that registers or unregisters during a sweep no longer affects that same sweep.EventsManager::set_callbackfires its backlog notification after installing the callback rather than before.What fails without this
Detector proven in both directions. With the fix reverted and the five deadline-guarded tests kept, all five fail:
With the fix, all five pass. Note the third: it does not hang, it returns a wrong answer (0 publishers). That one would have shipped silently.
Which half of the fix each detector is sensitive to, checked by reverting the two files separately, because it is not obvious from the test names:
event.rsgraph.rsevent_callback_unregistering_does_not_deadlockgraph_change_callback_registering_does_not_deadlockevent_callback_taking_its_own_status_does_not_deadlockevent_callback_reinstalling_itself_does_not_deadlockgraph_change_callback_querying_the_graph_does_not_deadlockReverting
event.rsalone leaves the graph-query test green; it only goes red once the liveliness subscriber holds theGraphDataguard acrosstrigger_graph_changeagain. Sograph.rsis not a supporting tidy-up — it is the sole cause of the wrong-answer failure, and it is the leg that closes the ABBA.No regression:
hiroz --lib287/287,reentrant_graph_event3/3,reentrant_event_status2/2.287, not the 286 an earlier revision of this description claimed. The extra one is this branch's own
graph_guard_condition_registration_is_owned_by_the_manager, added by the guard-condition commit; the stale figure predated it.Rebased onto
pr/1's current tip (a70f7336); this branch's four commits replayed with no conflicts, and its own diff is byte-identical before and after. All figures above were measured on the rebased head (0c274185).Coverage gap, stated plainly.
rmw-zenoh-rsis not built by this repo's pure-Rust CI at all — its build script needs ROS headers the CI shell does not have — and because this PR is based onpr/1rather thanmain,ci.ymldoes not run on it either (3 checks, not 27). The guard-condition redesign above is therefore the least CI-covered part of this change: it is the part with the most new code and the least automated evidence.cargo check -p rmw-zenoh-rs --all-targetsandcargo clippy -p rmw-zenoh-rs --all-targets -- -D warningswere both run against this exact head (0c274185) in a ROS-provisioned shell and are clean. That is the only automated evidence for this half; the interop suites have not run on it. Re-verify once the base moves tomainand the full matrix applies.Breaking Changes
Yes — two public items change, and both are source-breaking.
pub type EventCallbackgoesBox<dyn Fn(i32) + Send + Sync>→Arc<dyn Fn(i32) + Send + Sync>. Callers updateBox::newtoArc::new.pub type GraphGuardConditionTriggeris removed, not converted. It wasBox<dyn Fn(*mut c_void) + Send + Sync>; there is noArcequivalent of it in this branch. In its place,register_graph_guard_condition/unregister_graph_guard_conditiontakeArc<dyn GraphGuardCondition>— a trait, not a closure. A caller that previously handed over a boxed closure must now define a type and implementGraphGuardCondition forit. This is a larger break than (1) and is not a mechanical substitution.An earlier revision of this description claimed "None to the public API"; a later one said both aliases merely went
Box→Arc. Both were wrong, in the direction of understating the break.The redesign is load-bearing rather than stylistic.
Boxcannot be cloned, so it cannot be lifted out of a guard — which is the entire fix. And for guard conditions the closure form cannot express the ownership the fix requires: the registry must keep the target alive across a trigger it performs with no lock held, which needs shared ownership of the state, not a pointer captured in a closure.update_event_status[_with_policy]remain, now documented as being for callers that own the manager outright.Worth flagging for a follow-up rather than fixing here:
EventsManager::update_event_status[_with_policy]is stillpuband, taking&mut self, still necessarily runs the callback under whatever mutex the caller holds. Every in-tree caller was migrated to the shared-manager entry points, but the old one is re-armed for the next caller and is a candidate for#[deprecated]or removal.Notes
cargo docis red onmaintoday for unrelated reasons;pr/0-rustdoc-linksclears it. Run the reentrancy suites with--features ros-msgs,jazzy.Checklist
./scripts/check-local.shsuccessfully