6262//! thread must not wait — waiting for itself is the deadlock. A `set` on an
6363//! unrelated thread must wait, because it is the one about to free the pointer.
6464//!
65+ //! ## What this still does not survive
66+ //!
67+ //! Two threads *both* dispatching this entity's callback *and* both re-entering
68+ //! `set` from inside it will wait on each other. Each is in a live dispatch that
69+ //! may still touch its `user_data` after `set` returns, so neither wait can
70+ //! safely be skipped.
71+ //!
72+ //! Stated rather than hidden, because it is a real residual — but it is not a
73+ //! regression against the reference. `rmw_zenoh_cpp` cannot survive re-entrant
74+ //! `set` at all: `set_callback` takes `event_mutex_` (and replays its backlog
75+ //! under it), so a callback dispatched from `trigger_callback` that calls
76+ //! `set_callback` re-locks a non-recursive `std::mutex` on the thread that
77+ //! already holds it. That deadlocks with one thread. This deadlocks only with
78+ //! two threads mutually re-entering, and the single-threaded case — the one
79+ //! rclcpp actually exercises when an executor detaches from inside a
80+ //! notification — is the case this type makes work.
81+ //!
6582//! # Enforcement
6683//!
6784//! The mutex is a [`hiroz::reentrancy::TrackedMutex`], so its guards are counted
@@ -238,7 +255,6 @@ impl ExecCallback {
238255 /// in a single call, which is what lets an executor that attaches after
239256 /// messages have already arrived — the common startup race — see them.
240257 ///
241- /// [`notify_one`]: Self::notify_one
242258 /// # Blocking
243259 ///
244260 /// This returns only once every dispatch that captured the *outgoing*
@@ -248,6 +264,8 @@ impl ExecCallback {
248264 /// `rmw_zenoh_cpp` has the same property, where the wait is on
249265 /// `event_mutex_` instead. A callback re-entering on its own thread never
250266 /// waits.
267+ ///
268+ /// [`notify_one`]: Self::notify_one
251269 pub fn set ( & self , callback : Option < ExecCallbackFn > , user_data : * mut crate :: c_void ) {
252270 // Collect under the lock, registering the replay — if there is one —
253271 // before releasing it, for the same reason `notify_one` does: a
@@ -615,25 +633,33 @@ mod tests {
615633 assert_eq ! ( calls, 1 ) ;
616634 }
617635
618- /// A panicking callback must not leave its registration behind: the wait in
619- /// `set` would then never be satisfiable, turning the use-after-free into a
620- /// permanent hang. Covers the `DispatchToken` unwind path.
636+ /// An unwind through a live dispatch must not leave its registration
637+ /// behind: the wait in `set` would then never be satisfiable, turning the
638+ /// use-after-free into a permanent hang. Covers `DispatchToken`'s `Drop`.
639+ ///
640+ /// The unwind is raised directly rather than from a callback, because a
641+ /// panic *out of a callback* is not the reachable case: the callbacks are
642+ /// `extern "C"`, and Rust aborts rather than unwinding across that
643+ /// boundary — a panicking executor callback kills the process before any
644+ /// `Drop` runs. What can unwind here is Rust code on this side of the
645+ /// boundary, most notably `invoke_user_callback!`'s own debug assertion,
646+ /// which fires *before* the call.
621647 ///
622- /// The panic is caught, so the run prints one backtrace-ish line from the
623- /// default hook. That noise is expected.
648+ /// The panic is caught, so the run prints one line from the default hook.
649+ /// That noise is expected.
624650 #[ test]
625- fn a_panicking_callback_does_not_wedge_later_sets ( ) {
626- unsafe extern "C" fn panics ( _user_data : * const std:: ffi:: c_void , _count : usize ) {
627- panic ! ( "executor callback exploded" ) ;
628- }
629-
651+ fn an_unwind_through_a_dispatch_releases_its_registration ( ) {
630652 let exec = Executor :: new ( "client" , 0 ) ;
631- exec. slot . set ( Some ( panics) , exec. user_data ( ) ) ;
632653
633- let unwound = std:: panic:: catch_unwind ( AssertUnwindSafe ( || exec. slot . notify_one ( ) ) ) ;
654+ let unwound = std:: panic:: catch_unwind ( AssertUnwindSafe ( || {
655+ let _token = exec. slot . enter_dispatch ( ) ;
656+ panic ! ( "something unwound mid-dispatch" ) ;
657+ } ) ) ;
634658 assert ! ( unwound. is_err( ) , "the panic must still propagate" ) ;
635659
636- assert_completes ( "set() after a callback panicked" , move || {
660+ // If the registration leaked, this blocks forever: the wait is looking
661+ // for a dispatch on another thread that has already gone.
662+ assert_completes ( "set() after an unwind mid-dispatch" , move || {
637663 exec. slot . set ( None , std:: ptr:: null_mut ( ) ) ;
638664 } ) ;
639665 }
0 commit comments