Skip to main content

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}