proxima_primitives/sync/shutdown.rs
1//! Cross-core graceful shutdown with per-core resource ordering.
2//!
3//! Folded in from the former `proxima-shutdown` satellite crate. Pipes that
4//! hold `!Send` per-core state (open file handles, GPU contexts, FFI
5//! bindings, `Rc`/`RefCell` caches) register a drop closure here. The
6//! `ShutdownBarrier` broadcasts a drop signal to every core and waits for
7//! each core to drain its registered resources in LIFO order — newest
8//! registration first, mirroring stack-unwind semantics.
9//!
10//! ## Tiers
11//!
12//! [`ResourceRegistry`] is the `no_std` + `alloc` primitive: a per-core LIFO
13//! stack addressed by an explicit [`CoreId`], built on
14//! [`proxima_core::per_core::PerCore`] — the same `slot(core_id)`/`count()`
15//! shape `per_core` already establishes elsewhere in the workspace.
16//! [`register_per_core_resource`]/[`drain_current_core`] are the `std`-only
17//! ambient convenience on top (OS thread-local, no core id required at the
18//! call site) — the same role `PerCore::local` plays for its own primitive.
19//! `ShutdownBarrier` itself stays `std`-only: it needs [`crate::sync::Notify`].
20
21#[cfg(feature = "alloc")]
22use alloc::boxed::Box;
23#[cfg(feature = "alloc")]
24use alloc::string::String;
25#[cfg(feature = "std")]
26use alloc::sync::Arc;
27#[cfg(feature = "alloc")]
28use alloc::vec::Vec;
29#[cfg(feature = "alloc")]
30use core::cell::RefCell;
31#[cfg(feature = "std")]
32use core::future::Future;
33#[cfg(feature = "std")]
34use core::pin::Pin;
35
36#[cfg(feature = "std")]
37use portable_atomic::{AtomicUsize, Ordering};
38
39#[cfg(feature = "alloc")]
40use proxima_core::per_core::PerCore;
41
42#[cfg(feature = "alloc")]
43use proxima_runtime::CoreId;
44#[cfg(feature = "std")]
45use proxima_runtime::Runtime;
46
47#[cfg(feature = "alloc")]
48pub type DropHook = Box<dyn FnOnce()>;
49
50// shared by the ambient std stack and `ResourceRegistry`'s explicit-index
51// slots — one drain routine, two ways to name which stack to drain.
52#[cfg(feature = "alloc")]
53fn drain_stack(stack: &RefCell<Vec<(String, DropHook)>>) -> usize {
54 let drained = core::mem::take(&mut *stack.borrow_mut());
55 let count = drained.len();
56 for (_name, hook) in drained.into_iter().rev() {
57 hook();
58 }
59 count
60}
61
62/// `no_std` + `alloc` primitive: a per-core LIFO stack of cleanup hooks,
63/// addressed by explicit [`CoreId`] rather than ambient thread identity.
64/// Bare-metal or prime-style callers that already know their own core id
65/// use this directly; [`register_per_core_resource`]/[`drain_current_core`]
66/// are the `std`-only, TLS-routed convenience for callers with no core id
67/// at hand.
68#[cfg(feature = "alloc")]
69pub struct ResourceRegistry {
70 cores: PerCore<RefCell<Vec<(String, DropHook)>>>,
71}
72
73#[cfg(feature = "alloc")]
74impl ResourceRegistry {
75 #[must_use]
76 pub fn new(count: usize) -> Self {
77 Self {
78 cores: PerCore::new_with(count, |_index| RefCell::new(Vec::new())),
79 }
80 }
81
82 /// Register a cleanup hook for `core_id`. Fires on the matching
83 /// [`Self::drain`]. Multiple registrations under the same `name` are
84 /// allowed and all fire.
85 pub fn register(&self, core_id: CoreId, name: impl Into<String>, on_drop: DropHook) {
86 self.cores
87 .slot(core_id.as_usize())
88 .borrow_mut()
89 .push((name.into(), on_drop));
90 }
91
92 /// Drain every hook registered for `core_id`, LIFO. Returns the number
93 /// of hooks executed.
94 pub fn drain(&self, core_id: CoreId) -> usize {
95 drain_stack(self.cores.slot(core_id.as_usize()))
96 }
97
98 #[must_use]
99 pub fn count(&self) -> usize {
100 self.cores.count()
101 }
102}
103
104#[cfg(feature = "std")]
105std::thread_local! {
106 /// Per-core LIFO stack of cleanup hooks, keyed by whichever OS thread is
107 /// executing. Correct as long as the runtime pins one worker thread per
108 /// core — the same invariant `ShutdownBarrier` relies on when it
109 /// dispatches drain via `spawn_factory_on_core`. `RefCell` not `Mutex`:
110 /// the stack is single-owner (one worker thread per core).
111 static RESOURCES: RefCell<Vec<(String, DropHook)>> = const { RefCell::new(Vec::new()) };
112}
113
114/// Register a cleanup hook on the *current* core. The hook fires when the
115/// barrier broadcasts a drop signal here. Pipe authors call this from
116/// inside a `Pipe::call` future (or any per-core context) so the hook
117/// captures `!Send` state without ever crossing threads.
118///
119/// `name` is for diagnostics only; multiple registrations with the same
120/// name are allowed and all fire.
121#[cfg(feature = "std")]
122pub fn register_per_core_resource(name: impl Into<String>, on_drop: DropHook) {
123 RESOURCES.with(|stack| stack.borrow_mut().push((name.into(), on_drop)));
124}
125
126/// Drain every registered hook on the current core, LIFO. Each hook runs
127/// once and is dropped. Returns the number of hooks executed.
128#[cfg(feature = "std")]
129pub fn drain_current_core() -> usize {
130 RESOURCES.with(drain_stack)
131}
132
133/// Coordinates a multi-phase shutdown across every core of a `Runtime`.
134///
135/// Phases:
136/// 1. Caller signals listeners to stop accepting (separate path —
137/// `ListenerHandle::shutdown_signal()` / `Shutdown::stop()`).
138/// 2. Caller awaits in-flight drain via its own counters.
139/// 3. Caller invokes `broadcast_drop` here: every core runs
140/// `drain_current_core` in its own LocalSet so `!Send` hooks fire on
141/// their owning core. The future resolves when every core acks, or
142/// when `BROADCAST_DROP_DEADLINE` elapses, whichever comes first.
143/// 4. Caller drops the `Arc<Runtime>` to join worker threads.
144#[cfg(feature = "std")]
145pub struct ShutdownBarrier {
146 runtime: Arc<dyn Runtime>,
147}
148
149/// Ceiling on how long [`ShutdownBarrier::broadcast_drop`] waits for every
150/// core to ack before giving up and returning a partial [`ShutdownReport`].
151///
152/// `dispatch_factory`'s inbox is bounded (`prime/src/os/core_shard.rs`'s
153/// `try_send_mpsc`) -- under core saturation it returns
154/// `Err(SpawnError::InboxFull)`, which `broadcast_drop` already logs and
155/// treats as "this core's ack never bumps". Before this constant existed,
156/// that meant `report_notify.notified().await` waited forever: a single
157/// saturated core's dropped dispatch left the count permanently short of
158/// `num_cores`, hanging the entire shutdown with no way out -- exactly the
159/// failure the comment above it already claimed ("the barrier waits out
160/// its deadline") without a deadline ever being implemented.
161#[cfg(feature = "std")]
162const BROADCAST_DROP_DEADLINE: core::time::Duration = core::time::Duration::from_secs(5);
163
164#[cfg(feature = "std")]
165impl ShutdownBarrier {
166 #[must_use]
167 pub fn new(runtime: Arc<dyn Runtime>) -> Self {
168 Self { runtime }
169 }
170
171 /// Broadcasts a drop signal to every core and resolves once each
172 /// core has finished draining its per-core resources.
173 pub fn broadcast_drop(&self) -> Pin<Box<dyn Future<Output = ShutdownReport> + Send>> {
174 let num_cores = self.runtime.num_cores();
175 let acked = Arc::new(AtomicUsize::new(0));
176 let drained_total = Arc::new(AtomicUsize::new(0));
177 let notify = Arc::new(crate::sync::Notify::new());
178 for core_index in 0..num_cores {
179 let acked_for_core = acked.clone();
180 let drained_for_core = drained_total.clone();
181 let notify_for_core = notify.clone();
182 // Send factory crosses the channel; the produced ?Send future
183 // runs on the target worker's LocalSet, drains its thread_local
184 // resources there, then bumps the ack counter.
185 // Shutdown broadcast on inbox-full is a non-event: failing to
186 // deliver the drain factory just means that core's resources
187 // stay unrouted, the ack never bumps, and the barrier waits
188 // out its deadline. The right response is to log and move on —
189 // we cannot block startup of shutdown on every core having an
190 // empty inbox lane.
191 if let Err(err) = self.runtime.spawn_factory_on_core(
192 CoreId(core_index),
193 Box::new(move || {
194 Box::pin(async move {
195 let count = drain_current_core();
196 drained_for_core.fetch_add(count, Ordering::SeqCst);
197 if acked_for_core.fetch_add(1, Ordering::SeqCst) + 1 == num_cores {
198 notify_for_core.notify_one();
199 }
200 })
201 }),
202 ) {
203 tracing::warn!(
204 core_index,
205 error = %err,
206 "shutdown: drain factory not delivered; core will time out",
207 );
208 }
209 }
210 let report_notify = notify;
211 let report_acked = acked;
212 let report_drained = drained_total;
213 Box::pin(async move {
214 // Re-check in case every ack already arrived before we started awaiting.
215 // Bounded by BROADCAST_DROP_DEADLINE: a core whose dispatch was
216 // dropped for InboxFull above never bumps its ack, so this wait
217 // must not be unconditional -- see that constant's own doc. A
218 // timed-out wait still returns a `ShutdownReport` whose
219 // `cores_acked < num_cores` is the caller-visible signal that
220 // the deadline, not every core, ended the drain.
221 if report_acked.load(Ordering::SeqCst) < num_cores {
222 let _ =
223 proxima_core::time::timeout(BROADCAST_DROP_DEADLINE, report_notify.notified())
224 .await;
225 }
226 ShutdownReport {
227 cores_acked: report_acked.load(Ordering::SeqCst),
228 hooks_drained: report_drained.load(Ordering::SeqCst),
229 sources_drained: 0,
230 sources_aborted: 0,
231 }
232 })
233 }
234}
235
236#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
237pub struct ShutdownReport {
238 /// Number of cores that acknowledged the drop signal.
239 pub cores_acked: usize,
240 /// Total cleanup hooks fired across all cores.
241 pub hooks_drained: usize,
242 /// Registered `proxima_primitives::pipe::SourcePipe`s that observed cancellation and
243 /// returned within the drain grace window. Folded in by
244 /// `proxima::app::Shutdown::drain`; always `0` for a bare
245 /// `ShutdownBarrier::broadcast_drop` call, which knows nothing about
246 /// sources.
247 pub sources_drained: usize,
248 /// Registered sources that did NOT return within the grace window and
249 /// were aborted. See `sources_drained`.
250 pub sources_aborted: usize,
251}
252
253#[cfg(all(test, feature = "std"))]
254#[allow(clippy::unwrap_used, clippy::expect_used)]
255mod tests {
256 use super::*;
257 #[cfg(all(feature = "runtime-tokio", not(loom)))]
258 use std::sync::atomic::AtomicU64;
259 #[cfg(all(feature = "runtime-tokio", not(loom)))]
260 use std::time::Duration;
261
262 #[test]
263 fn lifo_drain_order_matches_registration_reverse() {
264 let log: Arc<std::sync::Mutex<Vec<&'static str>>> =
265 Arc::new(std::sync::Mutex::new(Vec::new()));
266 let log_first = log.clone();
267 let log_second = log.clone();
268 let log_third = log.clone();
269 register_per_core_resource(
270 "first",
271 Box::new(move || log_first.lock().unwrap().push("first")),
272 );
273 register_per_core_resource(
274 "second",
275 Box::new(move || log_second.lock().unwrap().push("second")),
276 );
277 register_per_core_resource(
278 "third",
279 Box::new(move || log_third.lock().unwrap().push("third")),
280 );
281 let count = drain_current_core();
282 assert_eq!(count, 3);
283 let order = log.lock().unwrap().clone();
284 assert_eq!(
285 order,
286 vec!["third", "second", "first"],
287 "LIFO order required"
288 );
289 assert_eq!(
290 drain_current_core(),
291 0,
292 "second drain must observe empty stack"
293 );
294 }
295
296 #[test]
297 fn resource_registry_lifo_drain_order_matches_registration_reverse() {
298 let registry = ResourceRegistry::new(2);
299 let log: Arc<std::sync::Mutex<Vec<&'static str>>> =
300 Arc::new(std::sync::Mutex::new(Vec::new()));
301 let log_first = log.clone();
302 let log_second = log.clone();
303 registry.register(
304 CoreId(1),
305 "first",
306 Box::new(move || log_first.lock().unwrap().push("first")),
307 );
308 registry.register(
309 CoreId(1),
310 "second",
311 Box::new(move || log_second.lock().unwrap().push("second")),
312 );
313
314 assert_eq!(registry.count(), 2);
315 assert_eq!(registry.drain(CoreId(0)), 0, "core 0 got no registrations");
316 assert_eq!(registry.drain(CoreId(1)), 2);
317 assert_eq!(*log.lock().unwrap(), vec!["second", "first"]);
318 assert_eq!(
319 registry.drain(CoreId(1)),
320 0,
321 "second drain observes empty stack"
322 );
323 }
324
325 #[cfg(all(feature = "runtime-tokio", not(loom)))]
326 #[test]
327 fn broadcast_drop_fires_hooks_on_every_core() {
328 use proxima_runtime::tokio::TokioPerCoreRuntime;
329
330 let runtime = Arc::new(TokioPerCoreRuntime::new(2).expect("build runtime"));
331 let runtime_for_barrier: Arc<dyn Runtime> = runtime.clone();
332 let drops_observed = Arc::new(AtomicU64::new(0));
333 let registered = Arc::new(AtomicU64::new(0));
334 let registered_notify = Arc::new(crate::sync::Notify::new());
335
336 // Register one drop hook on each core's thread_local. We dispatch
337 // a registration task to each core; the closure pushes onto its
338 // own per-core stack. Each factory bumps `registered` on completion
339 // so the test can wait for both registrations deterministically
340 // instead of guessing at a sleep duration.
341 for core_index in 0..2 {
342 let drops_for_core = drops_observed.clone();
343 let registered_for_core = registered.clone();
344 let notify_for_core = registered_notify.clone();
345 runtime
346 .spawn_factory_on_core(
347 CoreId(core_index),
348 Box::new(move || {
349 Box::pin(async move {
350 register_per_core_resource(
351 format!("core-{core_index}"),
352 Box::new(move || {
353 drops_for_core.fetch_add(1, Ordering::SeqCst);
354 }),
355 );
356 if registered_for_core.fetch_add(1, Ordering::SeqCst) + 1 == 2 {
357 notify_for_core.notify_one();
358 }
359 })
360 }),
361 )
362 .expect("test-time spawn must succeed on a fresh runtime");
363 }
364
365 futures::executor::block_on(async {
366 if registered.load(Ordering::SeqCst) < 2 {
367 proxima_core::time::timeout(Duration::from_secs(1), registered_notify.notified())
368 .await
369 .expect("registration hooks must complete within timeout");
370 }
371 });
372
373 let barrier = ShutdownBarrier::new(runtime_for_barrier);
374 let report = futures::executor::block_on(barrier.broadcast_drop());
375 assert_eq!(report.cores_acked, 2);
376 assert_eq!(report.hooks_drained, 2);
377 assert_eq!(drops_observed.load(Ordering::SeqCst), 2);
378 }
379}