Skip to content

Commit 6668e80

Browse files
committed
fix(rmw): stop invoking executor callbacks under a lock
All five remaining callback-under-lock sites in rmw-zenoh-rs invoked an rclcpp executor callback with one or two mutex guards still live: rmw_subscription_set_on_new_message_callback callback + unread_count subscription delivery notifier callback + callback_user_data service delivery notifier callback + callback_user_data client delivery notifier (send_request) callback + callback_user_data rmw_{service,client}_set_on_new_*_callback unread_count `std::sync::Mutex` is not reentrant, so a callback that re-enters the rmw API for the same entity blocks on a lock its own thread already holds. Installing and clearing on_new_message callbacks is how rclcpp executors attach and detach, so this is reachable, and it needs no race: the worst of the five is hit by the ordinary startup path where messages arrive before the executor attaches and the backlog is replayed under two guards. Replace the per-entity trio of mutexes with one `ExecCallback` slot whose two operations collect what they need under the lock, drop the guard, and only then call into user code. Collapsing three mutexes into one is part of the fix rather than tidying: with three, notification had to hold two guards at once to read a callback and its user-data together, and correctness rested on every site agreeing on a lock order. The slot uses hiroz's `TrackedMutex` and dispatches through `invoke_user_callback!`, so a reintroduction panics in debug with the site name instead of hanging. This crate had no coverage for this behaviour at all. Eight unit tests are added with it, including `no_guard_is_live_when_the_callback_runs` (asserts the live guard count is zero at the moment of dispatch), `delivery_notification_survives_reentry_into_itself` and `installing_a_callback_over_a_backlog_survives_reentry`, which reproduce the self-deadlock directly. Reverting the guard-drop and keeping them turns each into a hang. These five were not found by the manual sweep that produced the earlier fixes in this series. They were found by a mechanical pass over every lock acquisition site in the workspace: enumerate acquisitions, classify each guard's lifetime, then look inside that lifetime for a call into user code, plus a lock-order graph for ABBA inversions.
1 parent cd4f460 commit 6668e80

5 files changed

Lines changed: 413 additions & 175 deletions

File tree

Lines changed: 391 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,391 @@
1+
//! The executor-notification slot shared by every rmw entity that can wake an
2+
//! rclcpp executor: subscriptions, services, and clients.
3+
//!
4+
//! # The defect this type exists to prevent
5+
//!
6+
//! Each of those three entities used to carry the same trio of independent
7+
//! mutexes — `callback`, `callback_user_data`, `unread_count` — and every site
8+
//! that notified the executor locked one or two of them *and then invoked the
9+
//! callback while still holding the guard*:
10+
//!
11+
//! ```ignore
12+
//! if let Ok(cb) = callback_holder.lock() {
13+
//! if let Some(callback_fn) = *cb {
14+
//! if let Ok(user_data) = user_data_holder.lock() {
15+
//! unsafe { callback_fn(user_data_ptr, 1) }; // <-- executor, under two guards
16+
//! ```
17+
//!
18+
//! `std::sync::Mutex` is not reentrant. A callback that re-enters the rmw API
19+
//! for the same entity — which rclcpp does, because installing and clearing
20+
//! `on_new_message` callbacks is how executors attach and detach — blocks on a
21+
//! lock its own thread already holds. That is a deterministic hang, not a race.
22+
//!
23+
//! # The shape of the fix
24+
//!
25+
//! One mutex, and every operation *collects what it needs under the lock, drops
26+
//! the guard, and only then calls into user code*. This is the same pattern the
27+
//! hiroz core fixes established (`GraphEventManager::trigger_graph_change`,
28+
//! `ParameterState::validate_and_apply`, `update_shared_event_status`).
29+
//!
30+
//! Collapsing three mutexes into one is part of the fix, not incidental
31+
//! tidying: with three, "notify" had to hold two guards at once to read a
32+
//! callback and its user-data together, and the correctness of the whole thing
33+
//! rested on every site agreeing on a lock order. With one, the invariant is
34+
//! local and there is no order to get wrong.
35+
//!
36+
//! # Enforcement
37+
//!
38+
//! The mutex is a [`hiroz::reentrancy::TrackedMutex`], so its guards are counted
39+
//! on the current thread, and both call sites go through
40+
//! [`hiroz::invoke_user_callback!`], which asserts the count is zero before
41+
//! dispatching. In debug builds a reintroduction of the defect panics with the
42+
//! site name instead of hanging; in release both the counter and the assertion
43+
//! compile to nothing.
44+
45+
use std::sync::Arc;
46+
47+
use hiroz::invoke_user_callback;
48+
use hiroz::reentrancy::TrackedMutex;
49+
50+
/// The one callback signature rmw uses for all three "new item arrived"
51+
/// notifications. `rmw_subscription_new_message_callback_t`,
52+
/// `rmw_service_new_request_callback_t` and `rmw_client_new_response_callback_t`
53+
/// are all aliases of `Option<ExecCallbackFn>`.
54+
pub type ExecCallbackFn = unsafe extern "C" fn(user_data: *const std::ffi::c_void, count: usize);
55+
56+
#[derive(Default)]
57+
struct State {
58+
/// The executor callback, or `None` when no executor is attached.
59+
callback: Option<ExecCallbackFn>,
60+
/// The executor's opaque pointer, held as a `usize` so `State` stays `Send`.
61+
user_data: usize,
62+
/// Items that arrived while `callback` was `None`, replayed on install.
63+
unread: usize,
64+
}
65+
66+
/// Shared executor-notification state for one rmw entity.
67+
///
68+
/// Cloning shares the underlying slot; the delivery thread and the rmw API
69+
/// entry points hold clones of the same `ExecCallback`.
70+
#[derive(Clone)]
71+
pub struct ExecCallback {
72+
state: Arc<TrackedMutex<State>>,
73+
/// Entity kind, reproduced in the re-entrancy panic message.
74+
site: &'static str,
75+
}
76+
77+
impl ExecCallback {
78+
/// `site` names the entity kind ("subscription", "service", "client") and
79+
/// appears in the re-entrancy assertion message.
80+
pub fn new(site: &'static str) -> Self {
81+
Self {
82+
state: Arc::new(TrackedMutex::new(State::default())),
83+
site,
84+
}
85+
}
86+
87+
/// One new item arrived. Invokes the executor callback if one is installed,
88+
/// otherwise records the item as unread so it can be replayed by [`set`].
89+
///
90+
/// Runs on the zenoh delivery thread.
91+
///
92+
/// [`set`]: Self::set
93+
pub fn notify_one(&self) {
94+
// Collect under the lock...
95+
let armed = {
96+
let Ok(mut state) = self.state.lock() else {
97+
return;
98+
};
99+
match state.callback {
100+
Some(callback_fn) => {
101+
Some((callback_fn, state.user_data as *const std::ffi::c_void))
102+
}
103+
None => {
104+
state.unread += 1;
105+
None
106+
}
107+
}
108+
};
109+
// ...guard is dropped, and only now do we call into the executor.
110+
if let Some((callback_fn, user_data)) = armed {
111+
invoke_user_callback!(self.site, unsafe { callback_fn(user_data, 1) });
112+
}
113+
}
114+
115+
/// Install (or clear) the executor callback.
116+
///
117+
/// Backs the `rmw_subscription_set_on_new_message_callback`,
118+
/// `rmw_service_set_on_new_request_callback` and
119+
/// `rmw_client_set_on_new_response_callback` entry points.
120+
///
121+
/// Installing a callback replays any backlog accumulated by [`notify_one`]
122+
/// in a single call, which is what lets an executor that attaches after
123+
/// messages have already arrived — the common startup race — see them.
124+
///
125+
/// [`notify_one`]: Self::notify_one
126+
pub fn set(&self, callback: Option<ExecCallbackFn>, user_data: *mut crate::c_void) {
127+
// Collect under the lock...
128+
let backlog = {
129+
let Ok(mut state) = self.state.lock() else {
130+
return;
131+
};
132+
state.callback = callback;
133+
state.user_data = user_data as usize;
134+
match callback {
135+
Some(callback_fn) if state.unread > 0 => {
136+
Some((callback_fn, std::mem::take(&mut state.unread)))
137+
}
138+
_ => None,
139+
}
140+
};
141+
// ...guard is dropped, and only now do we call into the executor.
142+
if let Some((callback_fn, count)) = backlog {
143+
tracing::debug!(
144+
"[{}] replaying {} unread item(s) to a newly installed callback",
145+
self.site,
146+
count
147+
);
148+
invoke_user_callback!(self.site, unsafe {
149+
callback_fn(user_data as usize as *const std::ffi::c_void, count)
150+
});
151+
}
152+
}
153+
154+
/// Items that arrived with no callback installed. Test/introspection only.
155+
#[cfg(test)]
156+
fn unread(&self) -> usize {
157+
self.state.lock().map(|s| s.unread).unwrap_or(0)
158+
}
159+
}
160+
161+
impl std::fmt::Debug for ExecCallback {
162+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163+
f.debug_struct("ExecCallback")
164+
.field("site", &self.site)
165+
.finish_non_exhaustive()
166+
}
167+
}
168+
169+
#[cfg(test)]
170+
mod tests {
171+
use super::*;
172+
use std::sync::atomic::{AtomicUsize, Ordering};
173+
use std::sync::mpsc;
174+
use std::time::Duration;
175+
176+
/// How long a re-entrant call is given before we declare it deadlocked.
177+
/// The fixed code returns in microseconds; the pre-fix code never returns.
178+
const DEADLOCK_TIMEOUT: Duration = Duration::from_secs(5);
179+
180+
/// Stands in for the rclcpp executor: the object the `user_data` pointer
181+
/// actually points at, holding both the entity's slot and whatever
182+
/// bookkeeping the callback needs. Per-test, so the suite stays parallel.
183+
struct Executor {
184+
slot: ExecCallback,
185+
calls: AtomicUsize,
186+
last_count: AtomicUsize,
187+
/// How many more times the callback is allowed to re-enter. Bounded so
188+
/// a *correct* implementation terminates: with the fix, re-entry is
189+
/// legal and unbounded re-entry is unbounded recursion, which is a
190+
/// property of this callback and not of the code under test.
191+
reentries_left: AtomicUsize,
192+
}
193+
194+
impl Executor {
195+
fn new(site: &'static str, reentries: usize) -> Box<Self> {
196+
Box::new(Self {
197+
slot: ExecCallback::new(site),
198+
calls: AtomicUsize::new(0),
199+
last_count: AtomicUsize::new(0),
200+
reentries_left: AtomicUsize::new(reentries),
201+
})
202+
}
203+
204+
fn user_data(&self) -> *mut crate::c_void {
205+
self as *const Self as *mut crate::c_void
206+
}
207+
208+
fn enter(&self, count: usize) -> bool {
209+
self.calls.fetch_add(1, Ordering::SeqCst);
210+
self.last_count.store(count, Ordering::SeqCst);
211+
self.reentries_left
212+
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |n| n.checked_sub(1))
213+
.is_ok()
214+
}
215+
}
216+
217+
/// An executor callback that re-enters the rmw API on the same entity by
218+
/// clearing itself — what rclcpp does when an executor detaches from
219+
/// inside a notification.
220+
unsafe extern "C" fn reenters_by_clearing(user_data: *const std::ffi::c_void, count: usize) {
221+
let exec = unsafe { &*(user_data as *const Executor) };
222+
if exec.enter(count) {
223+
// Pre-fix, this blocks on a guard this very thread already holds.
224+
exec.slot.set(None, std::ptr::null_mut());
225+
}
226+
}
227+
228+
/// An executor callback that re-enters by asking for a fresh notification,
229+
/// covering the delivery-thread entry point rather than the install one.
230+
unsafe extern "C" fn reenters_by_notifying(user_data: *const std::ffi::c_void, count: usize) {
231+
let exec = unsafe { &*(user_data as *const Executor) };
232+
if exec.enter(count) {
233+
exec.slot.notify_one();
234+
}
235+
}
236+
237+
/// A callback that does not re-enter.
238+
unsafe extern "C" fn passive(user_data: *const std::ffi::c_void, count: usize) {
239+
let exec = unsafe { &*(user_data as *const Executor) };
240+
exec.calls.fetch_add(1, Ordering::SeqCst);
241+
exec.last_count.store(count, Ordering::SeqCst);
242+
}
243+
244+
/// Run `body` on a worker thread and fail if it has not finished within
245+
/// [`DEADLOCK_TIMEOUT`].
246+
///
247+
/// A deadlocked worker stays blocked for the lifetime of the test binary.
248+
/// That is deliberate: it cannot be unblocked, and a non-main thread does
249+
/// not keep the process alive, so the run still terminates and reports.
250+
fn assert_completes<T: Send + 'static>(
251+
what: &str,
252+
body: impl FnOnce() -> T + Send + 'static,
253+
) -> T {
254+
let (tx, rx) = mpsc::channel();
255+
std::thread::spawn(move || {
256+
let _ = tx.send(body());
257+
});
258+
match rx.recv_timeout(DEADLOCK_TIMEOUT) {
259+
Ok(v) => v,
260+
Err(_) => panic!(
261+
"{what} did not return within {DEADLOCK_TIMEOUT:?} — the executor \
262+
callback was invoked while a guard on the same mutex was live, so \
263+
the re-entrant call is blocked on this thread's own lock"
264+
),
265+
}
266+
}
267+
268+
// --- the deadlock detectors ---
269+
270+
/// Site 1 (the worst): `rmw_subscription_set_on_new_message_callback`
271+
/// replaying a backlog. Pre-fix this held the `callback` guard *and* the
272+
/// `unread_count` guard across the invocation, so a callback that touched
273+
/// either self-deadlocked. This is the common startup race: messages
274+
/// arrive before the executor attaches.
275+
#[test]
276+
fn installing_a_callback_over_a_backlog_survives_reentry() {
277+
let (calls, count) = assert_completes("set() replaying a backlog", || {
278+
let exec = Executor::new("subscription", 1);
279+
exec.slot.notify_one();
280+
exec.slot.notify_one();
281+
exec.slot.set(Some(reenters_by_clearing), exec.user_data());
282+
(
283+
exec.calls.load(Ordering::SeqCst),
284+
exec.last_count.load(Ordering::SeqCst),
285+
)
286+
});
287+
assert_eq!(calls, 1, "backlog replays in exactly one call");
288+
assert_eq!(count, 2, "and reports both unread items");
289+
}
290+
291+
/// Sites 2/3/4: the delivery-thread notifier, which pre-fix held the
292+
/// `callback` guard and the `callback_user_data` guard across the
293+
/// invocation.
294+
#[test]
295+
fn delivery_notification_survives_reentry_into_set() {
296+
let calls = assert_completes("notify_one() dispatching to the executor", || {
297+
let exec = Executor::new("service", 1);
298+
exec.slot.set(Some(reenters_by_clearing), exec.user_data());
299+
exec.slot.notify_one();
300+
exec.calls.load(Ordering::SeqCst)
301+
});
302+
assert_eq!(calls, 1);
303+
}
304+
305+
/// A callback that re-enters the *same* entry point it was dispatched
306+
/// from. Pre-fix this is a self-deadlock on `callback`.
307+
#[test]
308+
fn delivery_notification_survives_reentry_into_itself() {
309+
let calls = assert_completes("notify_one() re-entered from its own callback", || {
310+
let exec = Executor::new("client", 1);
311+
exec.slot.set(Some(reenters_by_notifying), exec.user_data());
312+
exec.slot.notify_one();
313+
exec.calls.load(Ordering::SeqCst)
314+
});
315+
assert_eq!(calls, 2, "outer dispatch plus one re-entrant dispatch");
316+
}
317+
318+
/// The tripwire is only meaningful if the guard really is released before
319+
/// dispatch. Assert that directly, rather than trusting an assertion that
320+
/// passes vacuously whenever the guard count is zero for the wrong reason.
321+
#[test]
322+
fn no_guard_is_live_when_the_callback_runs() {
323+
unsafe extern "C" fn record_live_guards(user_data: *const std::ffi::c_void, _c: usize) {
324+
let exec = unsafe { &*(user_data as *const Executor) };
325+
exec.last_count
326+
.store(hiroz::reentrancy::live_guards(), Ordering::SeqCst);
327+
}
328+
329+
let exec = Executor::new("subscription", 0);
330+
exec.last_count.store(usize::MAX, Ordering::SeqCst);
331+
exec.slot.notify_one();
332+
exec.slot.set(Some(record_live_guards), exec.user_data());
333+
assert_eq!(
334+
exec.last_count.load(Ordering::SeqCst),
335+
0,
336+
"set() must dispatch the backlog with no guard live"
337+
);
338+
339+
exec.last_count.store(usize::MAX, Ordering::SeqCst);
340+
exec.slot.notify_one();
341+
assert_eq!(
342+
exec.last_count.load(Ordering::SeqCst),
343+
0,
344+
"notify_one() must dispatch with no guard live"
345+
);
346+
}
347+
348+
// --- semantics preserved from the pre-fix code ---
349+
350+
#[test]
351+
fn items_arriving_with_no_callback_are_counted_not_dropped() {
352+
let exec = Executor::new("subscription", 0);
353+
exec.slot.notify_one();
354+
exec.slot.notify_one();
355+
exec.slot.notify_one();
356+
assert_eq!(exec.slot.unread(), 3);
357+
assert_eq!(exec.calls.load(Ordering::SeqCst), 0);
358+
}
359+
360+
#[test]
361+
fn installing_a_callback_drains_the_backlog() {
362+
let exec = Executor::new("subscription", 0);
363+
exec.slot.notify_one();
364+
exec.slot.set(Some(passive), exec.user_data());
365+
assert_eq!(exec.slot.unread(), 0, "backlog is reset once replayed");
366+
assert_eq!(exec.calls.load(Ordering::SeqCst), 1);
367+
}
368+
369+
#[test]
370+
fn installing_a_callback_with_no_backlog_does_not_dispatch() {
371+
let exec = Executor::new("subscription", 0);
372+
exec.slot.set(Some(passive), exec.user_data());
373+
assert_eq!(exec.calls.load(Ordering::SeqCst), 0);
374+
}
375+
376+
#[test]
377+
fn a_cleared_callback_goes_back_to_counting() {
378+
let exec = Executor::new("subscription", 0);
379+
exec.slot.set(Some(passive), exec.user_data());
380+
exec.slot.notify_one();
381+
assert_eq!(exec.calls.load(Ordering::SeqCst), 1);
382+
exec.slot.set(None, std::ptr::null_mut());
383+
exec.slot.notify_one();
384+
assert_eq!(
385+
exec.calls.load(Ordering::SeqCst),
386+
1,
387+
"no dispatch once cleared"
388+
);
389+
assert_eq!(exec.slot.unread(), 1, "and the item is counted instead");
390+
}
391+
}

crates/rmw-zenoh-rs/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ macro_rules! cfile {
1313

1414
pub mod common;
1515
pub mod context;
16+
pub mod exec_callback;
1617
pub mod guard_condition;
1718
pub mod msg;
1819
pub mod node;

0 commit comments

Comments
 (0)