Skip to content

fix(event,graph): run event callbacks outside the locks they live under - #260

Open
YuanYuYuan wants to merge 4 commits into
pr/1-reentrancy-tripwirefrom
pr/4b-event-graph-reentrancy
Open

fix(event,graph): run event callbacks outside the locks they live under#260
YuanYuYuan wants to merge 4 commits into
pr/1-reentrancy-tripwirefrom
pr/4b-event-graph-reentrancy

Conversation

@YuanYuYuan

@YuanYuYuan YuanYuYuan commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Description

Fixes #259

GraphEventManager invoked registered callbacks while holding the registries they are registered in, and EventsManager::update_event_status fired its callback from a &mut self method whose caller necessarily holds the shared outer mutex. The hot path in is the liveliness subscriber, 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 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 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.

Depends on pr/1-reentrancy-tripwire, which supplies TrackedMutex and invoke_user_callback!. Branched off it accordingly.

Stacked PR — this PR's base is pr/1-reentrancy-tripwire, not main. The diff therefore shows only this branch's own commit. Merge pr/1 first; GitHub will retarget this one to main automatically 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 in Session::resolve_put. EventCallback becomes Arc instead of Box so it can be cloned out cheaply.

  • crates/hiroz/src/graph.rs — the liveliness subscriber scopes its GraphData guard so it is released before any trigger, matching what add_local_entity already 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 self method, which does not own the guard and so cannot drop it. record_event_status_with_policy now 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 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_void can be freed by a concurrent rmw_destroy_node in the window between snapshot and call — the trigger would then write through dangling memory. So the GraphGuardConditionTrigger alias is removed and replaced by a GraphGuardCondition trait; the registry holds Arc<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 outside hiroz:

    • New guard_condition.rs splits the triggerable state (GuardConditionState) from the C handle, behind an Arc so it can outlive rmw_destroy_guard_condition. It implements hiroz::event::GraphGuardCondition.
    • triggered becomes an AtomicBool: triggering no longer happens under any lock, so a graph-event thread can set it while rmw_wait reads it. GuardConditionImpl's methods take &self, not &mut self, for the same reason.
    • node.rs registers the shared state, not the C pointer.
    • The eight update_event_status* call sites in rmw.rs move to update_shared_event_status*.
  • 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, all only observable to a callback that previously deadlocked:

  • trigger_graph_change now snapshots the notify list, so a callback that registers or unregisters during a sweep no longer affects that same sweep.
  • EventsManager::set_callback fires its backlog notification after installing the callback rather than before.
  • 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.

What fails without this

Detector proven in both directions. With the fix reverted and the five deadline-guarded 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. 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:

Detector Needs event.rs Needs graph.rs
event_callback_unregistering_does_not_deadlock yes
graph_change_callback_registering_does_not_deadlock yes
event_callback_taking_its_own_status_does_not_deadlock yes
event_callback_reinstalling_itself_does_not_deadlock yes
graph_change_callback_querying_the_graph_does_not_deadlock yes

Reverting event.rs alone leaves the graph-query test green; it only goes red once the liveliness subscriber holds the GraphData guard across trigger_graph_change again. So graph.rs is 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 --lib 287/287, reentrant_graph_event 3/3, reentrant_event_status 2/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-rs is 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 on pr/1 rather than main, ci.yml does 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-targets and cargo clippy -p rmw-zenoh-rs --all-targets -- -D warnings were 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 to main and the full matrix applies.

Breaking Changes

Yes — two public items change, and both are source-breaking.

  1. pub type EventCallback goes Box<dyn Fn(i32) + Send + Sync>Arc<dyn Fn(i32) + Send + Sync>. Callers update Box::new to Arc::new.

  2. pub type GraphGuardConditionTrigger is removed, not converted. It was Box<dyn Fn(*mut c_void) + Send + Sync>; there is no Arc equivalent of it in this branch. In its place, register_graph_guard_condition / unregister_graph_guard_condition take Arc<dyn GraphGuardCondition> — a trait, not a closure. A caller that previously handed over a boxed closure must now define a type and implement GraphGuardCondition for it. 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 BoxArc. Both were wrong, in the direction of understating the break.

The redesign is load-bearing rather than stylistic. Box cannot 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 still pub and, 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 doc is red on main today for unrelated reasons; pr/0-rustdoc-links clears it. Run the reentrancy suites with --features ros-msgs,jazzy.

Checklist

  • Ran ./scripts/check-local.sh successfully
  • Added/updated tests/documentation (if applicable)

@YuanYuYuan
YuanYuYuan force-pushed the pr/1-reentrancy-tripwire branch from ad3a4d7 to 9bc6dfd Compare July 28, 2026 06:35
@YuanYuYuan
YuanYuYuan force-pushed the pr/4b-event-graph-reentrancy branch from ac4f3ec to 6008b3f Compare July 28, 2026 07:53
@YuanYuYuan
YuanYuYuan requested a review from Copilot July 28, 2026 07:54

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread crates/hiroz/src/event.rs Outdated
Comment thread crates/hiroz/src/event.rs
Comment thread crates/hiroz/src/event.rs Outdated
Comment thread crates/hiroz-tests/tests/reentrant_graph_event.rs
Comment thread crates/hiroz/src/event.rs Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_callback still invokes the backlog callback from a &mut self method. A public caller using the normal shared shape (manager.lock().unwrap().set_callback(...)) therefore keeps the outer Mutex<EventsManager> guard alive here, and a callback that calls take_event deterministically deadlocks. The install_callback split only avoids this through RmEventHandle; 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's invoke_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 in reentrancy.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-49 that every user-code call site uses invoke_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::trigger is an externally implemented callback (and the surrounding comment explicitly says it may re-enter the manager), but this dispatch bypasses invoke_user_callback!. Per reentrancy.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 GraphGuardConditionTrigger merely changes from Box to Arc, but this diff actually removes that public alias and set_guard_condition_trigger, introduces a new trait, and changes registration from a raw pointer to Arc<dyn GraphGuardCondition>. Downstream migration is therefore not the stated one-token Box::newArc::new update; 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-49 requires every user-code invocation to go through invoke_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);
        }

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 GraphGuardConditionTrigger and the diff also removes set_guard_condition_trigger and changes both registration signatures. The PR description instead says the alias merely changes from Box to Arc and 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 in crates/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

  • GraphGuardCondition is a public, externally implementable trait, so trigger() is also an invocation of user-supplied code. Per the invariant in crates/hiroz/src/reentrancy.rs:43-49, dispatch it through invoke_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 in crates/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_callbacks before inserting entity_topics makes registration observable in a partial state: trigger_event can already invoke the new callback, while a concurrent trigger_graph_change can 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 through invoke_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);

Comment thread crates/rmw-zenoh-rs/src/guard_condition.rs
@YuanYuYuan
YuanYuYuan force-pushed the pr/4b-event-graph-reentrancy branch 4 times, most recently from cd4f460 to 903f62b Compare July 28, 2026 13:09
@YuanYuYuan
YuanYuYuan force-pushed the pr/1-reentrancy-tripwire branch from 21c96f5 to 69326f0 Compare July 28, 2026 14:23
@YuanYuYuan
YuanYuYuan force-pushed the pr/4b-event-graph-reentrancy branch from 903f62b to 2126252 Compare July 28, 2026 14:23
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants