Skip to content

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

Merged
YuanYuYuan merged 7 commits into
mainfrom
pr/4b-event-graph-reentrancy
Aug 6, 2026
Merged

fix(event,graph): run event callbacks outside the locks they live under#260
YuanYuYuan merged 7 commits into
mainfrom
pr/4b-event-graph-reentrancy

Conversation

@YuanYuYuan

@YuanYuYuan YuanYuYuan commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

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. Targets main; the merge base is af00f1ed, the current tip of main.

Issue

Fixes #259 — three failures spanning two defect classes.

# Failure Class Evidence
1 GraphEventManager invoked registered callbacks while holding the registries they live in. The hot path is the liveliness subscriber in Graph::new_with_pattern, which also held the GraphData mutex — so three locks (GraphData, event_callbacks, entity_topics) on every liveliness token, around user code the rmw layer hands to an rclcpp executor re-entrancy deterministic hang, reproduced by test
2 Lock-order hazard. The liveliness callback held GraphData across the acquisition of event_callbacks, so that order constrained every other path; add_local_entity and the rmw callers reach event_callbacks without GraphData. #259 classifies this as an ABBA inversion lock ordering established by reading, not by executing — no test reproduces it
3 EventsManager is shared as Arc<Mutex<..>>, so &mut self proves the caller holds it — and update_event_status fired the callback from there. All eight call sites in crates/rmw-zenoh-rs/src/rmw.rs had this shape re-entrancy deterministic hang, reproduced by test

Failure 2 is not covered by the "no callout under a lock" property. A guard count cannot see lock order.

What this PR does

# Change Why
1 trigger_event_with_policy, trigger_graph_change and the guard-condition sweep collect under the lock, drop every guard, then notify the class fix
2 Graph::new_with_pattern stops holding GraphData across trigger_graph_change the third lock of failure 1, and the GraphData-then-event_callbacks leg of failure 2
3 record_event_status_with_policy returns the callback (#[must_use]); update_shared_event_status[_with_policy] becomes the entry point holders of the shared manager use failure 3
4 event_callbacks, entity_topics and graph_guard_conditions become TrackedMutex; the two GraphEventManager callouts route through invoke_user_callback! a reintroduction panics naming the site instead of hanging
5 GuardConditionState::take_triggered (one swap) replaces read-then-reset in rmw_wait independent bug: a trigger() landing between the two atomic ops was swallowed — that wake was reported and the next rmw_wait blocked until timeout
6 GraphGuardConditionTrigger removed; registrations become Arc<dyn GraphGuardCondition>, and NodeImpl keeps the same allocation so teardown can unregister it triggering outside the lock made raw-pointer registrations a use-after-free — rmw_destroy_node unregisters and frees while a trigger is in flight
7 The event user_data pointer stops being laundered through usize; it moves in a newtype with explicit unsafe impl Send/Sync the as usize round-trip silenced the Send/Sync check that was flagging a pointer crossing threads, and destroyed the pointer's provenance

Change 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-rs half 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 in reentrant_event_status.rs) and one unit test in event.rs pinning that the guard-condition registry owns its registrations.

measurement result revision
the six new tests, plus fmt, clippy and doc build all pass, all clean e285962c (current tip)
revert event.rs and graph.rs, keep the three reentrant_graph_event tests 3 fail — two on the 30 s deadline, one because the re-entrant graph query returned 0 publishers cbabd82c (earlier revision of this branch, before the rebase onto af00f1ed)
revert wholesale, keep the two reentrant_event_status tests does not compile — both call an entry point this PR introduces as above
reinstate the callout inside update_shared_event_status_with_policy and re-run 2 fail on the 30 s deadline temporary revert commit on the same revision

The 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.

⚠️ The two revert directions were measured on the pre-rebase revision of this branch and have not been re-run at e285962c. The fix-present row was measured at the current tip.

Breaking changes

Three public items in hiroz, all source-breaking. rmw-zenoh-rs is consumed over the C ABI, so its changes are not part of this list.

# Item Before → After Action Effort
1 pub type EventCallback Box<dyn Fn(i32) + Send + Sync>Arc<…> Box::newArc::new mechanical
2 pub type GraphGuardConditionTrigger removed; register_graph_guard_condition / unregister_graph_guard_condition now take Arc<dyn GraphGuardCondition> define a type and impl GraphGuardCondition not mechanical — a closure cannot be substituted
3 GraphEventManager::set_guard_condition_trigger removed register the guard condition itself; there is no separate trigger hook small, but no drop-in

Neither of the first two is a rename:

  • Box cannot be cloned, so it cannot be lifted out of a guard — and lifting the callback out is the fix.
  • The closure form cannot express the ownership a guard condition needs: the registry must keep the target alive across a trigger performed with no lock held.

Additive, not breaking: EventsManager::install_callback and EventsManager::record_event_status_with_policy are new; update_shared_event_status[_with_policy] are new free functions.

Coverage this does not have

gap detail
GraphData is untracked it is a parking_lot::Mutex. Reintroduce the deleted guard at the top of the liveliness callback and assert_no_guards_held still 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 propagation
nothing covers failure 2 lock ordering is invisible to a guard count
five callouts in event.rs bypass invoke_user_callback! EventsManager::set_callback (backlog), EventsManager::update_event_status_with_policy, gc.trigger() inside trigger_graph_change, update_shared_event_status_with_policy, RmEventHandle::set_callback (backlog). Only the two GraphEventManager registry callouts are routed
change 5 has no detector — measured, not suspected reverting take_triggered to 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 it
changes 6 and 7 are believed to have no behavioural detector stated as a belief; not probed

⚠️ Known residual this fix introduces

An event callback can fire after rmw_event_set_callback(event, nullptr, nullptr) returns, and invoke a stale user_data. Tracked in #287 with the design settled.

Trigger Thread A clones the Arc out and releases the manager lock. Thread B (executor teardown) calls set_callback(nullptr), which returns; rclcpp frees the pointee. Thread A then calls the closure.
Why it is new The invocation used to happen inside Mutex<EventsManager>, which RmEventHandle::set_callback also takes, so a detach could not return mid-invocation. That exclusion was accidental but load-bearing.
Why the Arc does not help It owns the closure. The raw C pointer it captured is rclcpp's — which is why change 6's remedy cannot be reused here.
Not fixable by re-locking (that is the deadlock this removes), or a flag checked in the closure (the free can land between check and call, and the code then looks defended)
What does fix it detach blocks until any in-flight invocation returns, with a bypass when called from the invoking thread — the shape of std::stop_callback's destructor contract

This does not block merging, but it is a real trade, and it is not a gap shared with upstream. rmw_zenoh_cpp does not have this seam: EventsManager::event_set_callback (rmw_zenoh_cpp/src/detail/event.cpp:105) and EventsManager::trigger_event_callback (:133) both take event_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_cpp source, not inferred. Paths below are relative to rmw_zenoh_cpp/src/detail/.

path rmw_zenoh_cpp this PR
graph discovery callbacks collect, release, call — parse_put unlocks graph_mutex_ at graph_cache.cpp:428 before invoking, with the graph_mutex_ → discovery_mutex_ order documented at :205 same
QoS event callbacks still under two locks: parse_put holds graph_mutex_ (graph_cache.cpp:367) through handle_matched_events_for_put (:180) into update_event_counters, which locks events_mutex_ (:1496) and invokes the callback at :1528 released
event set / trigger invoked under the same mutex event_set_callback takes (event.cpp:118, :126, :143, :146) released → #287
  • The fix shape is independently confirmed. Upstream reached collect-release-call for discovery, with a comment giving the same reason (graph_cache.cpp:210-213).
  • This PR is ahead on the event path. Upstream still holds two locks across the QoS event callout — that is failure 1, unfixed in C++.
  • The Event callback can outlive rmw_event_set_callback(.., null) and use freed user_data #287 tradeoff is a genuine choice, not a regression against a peer. Upstream keeps the lock: no use-after-free, but the deadlock. This takes the other horn. Neither has both, which is the argument for the barrier rather than for either status quo.

Checklist

@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
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.
@YuanYuYuan
YuanYuYuan force-pushed the pr/4b-event-graph-reentrancy branch from cbabd82 to 83b3e21 Compare August 5, 2026 16:54
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.
@YuanYuYuan
YuanYuYuan merged commit ae4f0f8 into main Aug 6, 2026
31 checks passed
@YuanYuYuan
YuanYuYuan deleted the pr/4b-event-graph-reentrancy branch August 6, 2026 10:13
YuanYuYuan added a commit that referenced this pull request Aug 6, 2026
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.
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.

Graph and event callbacks are invoked with up to four locks held (plus an ABBA inversion)

2 participants