|
| 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 | +} |
0 commit comments