Skip to main content

proxima_primitives/pipe/
lifecycle.rs

1//! Background-task lifecycle for [`SourcePipe`](crate::pipe::source::SourcePipe)s.
2//!
3//! A `SourcePipe` is a `Signal -> ()` pipe that runs an unbounded loop and
4//! observes a cancellation [`Signal`] cooperatively — the signal it receives
5//! IS the loop's cancellation arm, not a wrapper's. This module is the
6//! runtime that *spawns* registered sources, owns the shared cancellation
7//! tree, and drains the join set on shutdown. It lives alongside
8//! [`SourcePipe`](crate::pipe::source::SourcePipe) so the lifecycle sits
9//! adjacent to the trait it serves — no downstream crate has to reinvent
10//! shutdown-grace or panic-propagation.
11//!
12//! # Composed primitives
13//!
14//! - [`crate::sync::task::JoinSet`] — task set (`spawn` / `join_next`).
15//!   Under `runtime-tokio` a thin forwarder over `tokio::task::JoinSet`
16//!   (identical behaviour to the pre-migration direct `tokio::task::JoinSet`
17//!   use); otherwise a tokio-free OS-thread-per-task backing so this stays
18//!   reachable from the default, tokio-free build.
19//! - [`proxima_core::signal::Signal`] — cooperative cancellation
20//!   tree. Parent tokens propagate to children via [`with_parent_signal`].
21//! - [`proxima_core::time::timeout`] — runtime-agnostic grace deadline.
22//! - [`crate::pipe::source::SourceHandle`] — the erased source a caller registers
23//!   via `spawn_from_source`; the same [`Signal`] instance it receives is
24//!   the one the caller fires from [`shutdown`].
25//!
26//! [`with_parent_signal`]: ProducerLifecycle::with_parent_signal
27//! [`shutdown`]: ProducerLifecycle::shutdown
28
29use alloc::sync::Arc;
30use alloc::vec::Vec;
31use core::time::Duration;
32
33use proxima_core::signal::Signal;
34use tracing::{error, trace, warn};
35
36use crate::pipe::SendPipe;
37use crate::pipe::source::SourceHandle;
38use crate::sync::task::JoinSet;
39
40/// Manages background-task lifecycle for a set of [`SourceHandle`]s.
41///
42/// The lifecycle:
43/// 1. Construct with [`ProducerLifecycle::new`] (or [`with_parent_signal`] to
44///    inherit a shared cancellation tree).
45/// 2. For each registered source, call [`spawn_from_source`] — the source's
46///    own loop observes the lifecycle's [`Signal`] cooperatively and returns
47///    once it has drained.
48/// 3. Hand callers a clone of [`cancel_signal`] if they need to coordinate
49///    their own shutdown observation.
50/// 4. On shutdown, call [`shutdown`] with a grace [`Duration`]. The signal is
51///    fired; tasks that finish within the grace count as `drained`;
52///    tasks that don't are aborted and count as `aborted`.
53///
54/// [`with_parent_signal`]: ProducerLifecycle::with_parent_signal
55/// [`spawn_from_source`]: ProducerLifecycle::spawn_from_source
56/// [`cancel_signal`]: ProducerLifecycle::cancel_signal
57/// [`shutdown`]: ProducerLifecycle::shutdown
58pub struct ProducerLifecycle {
59    tasks: JoinSet<()>,
60    cancel: Signal,
61    spawned_names: Vec<Arc<str>>,
62}
63
64impl ProducerLifecycle {
65    /// Construct a new lifecycle with a fresh root cancellation token.
66    #[must_use]
67    pub fn new() -> Self {
68        Self {
69            tasks: JoinSet::new(),
70            cancel: Signal::new(),
71            spawned_names: Vec::new(),
72        }
73    }
74
75    /// Construct from a parent token; cancelling the parent cancels this
76    /// lifecycle's child token too. Useful when a higher-level shutdown
77    /// (e.g. the app-level oneshot signal) owns the root token.
78    #[must_use]
79    pub fn with_parent_signal(parent: &Signal) -> Self {
80        Self {
81            tasks: JoinSet::new(),
82            cancel: parent.child(),
83            spawned_names: Vec::new(),
84        }
85    }
86
87    /// Return a clone of the cancellation token. Callers that need to observe
88    /// shutdown alongside the spawned tasks (e.g. the listener serve loop)
89    /// can race against this.
90    #[must_use]
91    pub fn cancel_signal(&self) -> Signal {
92        self.cancel.clone()
93    }
94
95    /// Spawn `source` under this lifecycle's shared cancellation [`Signal`].
96    ///
97    /// Unlike a wrapper-cancelled task, the source's own loop receives the
98    /// lifecycle's real `Signal` as its `call` input and observes it
99    /// cooperatively (see `run_interval_loop`'s `select_biased!` against
100    /// `cancel.fired()`) — cancellation is not drop-based, the source returns
101    /// on its own once it has drained.
102    pub fn spawn_from_source(&mut self, name: &str, source: &SourceHandle) {
103        let label: Arc<str> = Arc::from(name);
104        let cancel = self.cancel.clone();
105        let source = source.clone();
106        self.spawned_names.push(label.clone());
107        self.tasks.spawn(async move {
108            match SendPipe::call(&source, cancel).await {
109                Ok(()) => trace!(source = %label, "producer-lifecycle source completed"),
110                Err(err) => error!(?err, source = %label, "producer-lifecycle source failed"),
111            }
112        });
113    }
114
115    /// Number of tasks currently being managed.
116    #[must_use]
117    pub fn task_count(&self) -> usize {
118        self.spawned_names.len()
119    }
120
121    /// Names of all spawned tasks (in spawn order), per [`spawn_from_source`].
122    ///
123    /// [`spawn_from_source`]: ProducerLifecycle::spawn_from_source
124    pub fn spawned_task_names(&self) -> impl Iterator<Item = &str> {
125        self.spawned_names.iter().map(AsRef::as_ref)
126    }
127
128    /// Initiate graceful shutdown.
129    ///
130    /// 1. Cancel the lifecycle's token so cooperating tasks observe cancellation.
131    /// 2. Drain the join set with the given `grace` deadline. Tasks that exit
132    ///    within the grace count as `drained`; tasks that don't are aborted
133    ///    and count as `aborted`. Tasks that panicked are noted in
134    ///    [`ShutdownReport::panics`] and count as `aborted` (the task did
135    ///    not drain cleanly).
136    ///
137    /// Consumes `self`, so the lifecycle cannot be re-used after shutdown.
138    pub async fn shutdown(mut self, grace: Duration) -> ShutdownReport {
139        let total = self.spawned_names.len();
140        self.cancel.fire();
141
142        let mut drained = 0usize;
143        let mut panics = 0usize;
144        let drain_complete = proxima_core::time::timeout(grace, async {
145            while let Some(result) = self.tasks.join_next().await {
146                match result {
147                    Ok(()) => drained += 1,
148                    Err(err) if err.is_cancelled() => {
149                        // cancelled via abort_all in the timeout branch
150                    }
151                    Err(err) if err.is_panic() => {
152                        panics += 1;
153                        error!(?err, "producer-lifecycle task panicked");
154                    }
155                    Err(err) => {
156                        warn!(
157                            ?err,
158                            "producer-lifecycle task ended with non-panic, non-cancel JoinError"
159                        );
160                    }
161                }
162            }
163        })
164        .await;
165
166        if drain_complete.is_err() {
167            // grace exceeded — abort remaining tasks and reap their handles
168            self.tasks.abort_all();
169            while let Some(result) = self.tasks.join_next().await {
170                if let Err(err) = result
171                    && err.is_panic()
172                {
173                    panics += 1;
174                    error!(?err, "producer-lifecycle task panicked during abort");
175                }
176            }
177        }
178
179        let aborted = total.saturating_sub(drained);
180        ShutdownReport {
181            total,
182            drained,
183            aborted,
184            panics,
185        }
186    }
187}
188
189impl Default for ProducerLifecycle {
190    fn default() -> Self {
191        Self::new()
192    }
193}
194
195impl core::fmt::Debug for ProducerLifecycle {
196    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
197        formatter
198            .debug_struct("ProducerLifecycle")
199            .field("task_count", &self.spawned_names.len())
200            .field("cancelled", &self.cancel.is_fired())
201            .finish()
202    }
203}
204
205/// Outcome of a [`ProducerLifecycle::shutdown`] call.
206#[derive(Debug, Clone, Copy, Eq, PartialEq)]
207pub struct ShutdownReport {
208    /// Total tasks the lifecycle was managing at shutdown time.
209    pub total: usize,
210    /// Tasks that exited cleanly (cooperatively, within the grace deadline).
211    pub drained: usize,
212    /// Tasks that did NOT exit within the grace deadline and were aborted.
213    pub aborted: usize,
214    /// Tasks that ended with a panic (counted as part of [`aborted`]).
215    ///
216    /// [`aborted`]: ShutdownReport::aborted
217    pub panics: usize,
218}
219
220#[cfg(test)]
221mod tests {
222    #![allow(
223        clippy::unwrap_used,
224        clippy::expect_used,
225        clippy::field_reassign_with_default,
226        clippy::type_complexity,
227        clippy::useless_vec,
228        clippy::needless_range_loop,
229        clippy::default_constructed_unit_structs
230    )]
231
232    use alloc::sync::Arc;
233    use core::future::Future;
234    use core::sync::atomic::{AtomicUsize, Ordering};
235    use core::time::Duration;
236
237    use futures::FutureExt;
238    use proxima_core::ProximaError;
239
240    use crate::pipe::source::{SourceHandle, into_source_handle};
241
242    use super::*;
243
244    struct NoopSource;
245
246    impl SendPipe for NoopSource {
247        type In = Signal;
248        type Out = ();
249        type Err = ProximaError;
250
251        fn call(&self, _cancel: Signal) -> impl Future<Output = Result<(), ProximaError>> + Send {
252            async { Ok(()) }
253        }
254    }
255
256    /// Awaits `cancel.fired()` then records the observation — proves
257    /// cooperative cancellation reaches the source's own loop.
258    struct CooperativeSource {
259        observed: Arc<AtomicUsize>,
260    }
261
262    impl SendPipe for CooperativeSource {
263        type In = Signal;
264        type Out = ();
265        type Err = ProximaError;
266
267        fn call(&self, cancel: Signal) -> impl Future<Output = Result<(), ProximaError>> + Send {
268            let observed = self.observed.clone();
269            async move {
270                cancel.fired().await;
271                observed.fetch_add(1, Ordering::SeqCst);
272                Ok(())
273            }
274        }
275    }
276
277    /// Races a never-ending body against `cancel.fired()`, mirroring
278    /// `run_interval_loop`'s own `select_biased!` — the source itself owns
279    /// cancellation, not a lifecycle wrapper.
280    struct LongRunningSource;
281
282    impl SendPipe for LongRunningSource {
283        type In = Signal;
284        type Out = ();
285        type Err = ProximaError;
286
287        fn call(&self, cancel: Signal) -> impl Future<Output = Result<(), ProximaError>> + Send {
288            async move {
289                let body = core::future::pending::<()>().fuse();
290                let cancel_fut = cancel.fired().fuse();
291                futures::pin_mut!(body, cancel_fut);
292                futures::select_biased! {
293                    () = cancel_fut => Ok(()),
294                    () = body => Ok(()),
295                }
296            }
297        }
298    }
299
300    struct PanickingSource;
301
302    impl SendPipe for PanickingSource {
303        type In = Signal;
304        type Out = ();
305        type Err = ProximaError;
306
307        fn call(&self, _cancel: Signal) -> impl Future<Output = Result<(), ProximaError>> + Send {
308            async { panic!("intentional test panic") }
309        }
310    }
311
312    #[proxima::test]
313    async fn empty_lifecycle_shutdown_reports_zero_total() {
314        let lifecycle = ProducerLifecycle::new();
315        let report = lifecycle.shutdown(Duration::from_millis(100)).await;
316        assert_eq!(
317            report,
318            ShutdownReport {
319                total: 0,
320                drained: 0,
321                aborted: 0,
322                panics: 0,
323            }
324        );
325    }
326
327    #[proxima::test]
328    async fn spawned_source_completes_cleanly_drains_within_grace() {
329        let source: SourceHandle = into_source_handle(NoopSource);
330
331        let mut lifecycle = ProducerLifecycle::new();
332        lifecycle.spawn_from_source("noop_producer", &source);
333        assert_eq!(lifecycle.task_count(), 1);
334
335        let report = lifecycle.shutdown(Duration::from_secs(1)).await;
336        assert_eq!(report.total, 1);
337        assert_eq!(report.drained, 1);
338        assert_eq!(report.aborted, 0);
339        assert_eq!(report.panics, 0);
340    }
341
342    #[proxima::test]
343    async fn cooperative_source_respects_cancellation_token_drained_count_one() {
344        let observed = Arc::new(AtomicUsize::new(0));
345        let source: SourceHandle = into_source_handle(CooperativeSource {
346            observed: observed.clone(),
347        });
348
349        let mut lifecycle = ProducerLifecycle::new();
350        lifecycle.spawn_from_source("cooperating_producer", &source);
351
352        let report = lifecycle.shutdown(Duration::from_secs(1)).await;
353        assert_eq!(report.total, 1);
354        assert_eq!(report.drained, 1);
355        assert_eq!(report.aborted, 0);
356        assert_eq!(observed.load(Ordering::SeqCst), 1);
357    }
358
359    #[proxima::test]
360    async fn long_running_source_drained_promptly_by_its_own_select_on_cancel() {
361        // The source races its own body against cancel.fired(). On shutdown
362        // the cancel arm wins, so the source returns Ok(()) cleanly.
363        // Drained == 1, aborted == 0 — a grace deadline is a safety net for
364        // the pathological case where a source never observes cancel at all.
365        let source: SourceHandle = into_source_handle(LongRunningSource);
366
367        let mut lifecycle = ProducerLifecycle::new();
368        lifecycle.spawn_from_source("long_running_producer", &source);
369
370        let started = std::time::Instant::now();
371        let report = lifecycle.shutdown(Duration::from_millis(500)).await;
372        let elapsed = started.elapsed();
373
374        assert!(
375            elapsed < Duration::from_millis(500),
376            "shutdown should drain via the source's own select! well before the grace deadline: {elapsed:?}"
377        );
378        assert_eq!(report.total, 1);
379        assert_eq!(
380            report.drained, 1,
381            "the source exits cleanly on cancel via its own select!"
382        );
383        assert_eq!(report.aborted, 0);
384        assert_eq!(report.panics, 0);
385    }
386
387    #[proxima::test]
388    async fn multiple_sources_spawn_and_drain_separately() {
389        let mut lifecycle = ProducerLifecycle::new();
390        lifecycle.spawn_from_source("a", &into_source_handle(NoopSource));
391        lifecycle.spawn_from_source("b", &into_source_handle(NoopSource));
392        lifecycle.spawn_from_source("c", &into_source_handle(NoopSource));
393        assert_eq!(lifecycle.task_count(), 3);
394
395        let names: Vec<&str> = lifecycle.spawned_task_names().collect();
396        assert_eq!(names, alloc::vec!["a", "b", "c"]);
397
398        let report = lifecycle.shutdown(Duration::from_secs(1)).await;
399        assert_eq!(report.drained, 3);
400    }
401
402    #[proxima::test]
403    async fn source_panic_is_counted_but_does_not_crash_lifecycle() {
404        let mut lifecycle = ProducerLifecycle::new();
405        lifecycle.spawn_from_source("oops", &into_source_handle(PanickingSource));
406        lifecycle.spawn_from_source("ok", &into_source_handle(NoopSource));
407
408        let report = lifecycle.shutdown(Duration::from_secs(1)).await;
409        assert_eq!(report.total, 2);
410        assert_eq!(report.panics, 1, "one source should have panicked");
411        assert_eq!(report.drained + report.aborted, 2);
412    }
413
414    #[proxima::test]
415    async fn parent_token_cancellation_propagates_to_child_lifecycle() {
416        let parent = Signal::new();
417        let mut lifecycle = ProducerLifecycle::with_parent_signal(&parent);
418
419        let observed = Arc::new(AtomicUsize::new(0));
420        let source: SourceHandle = into_source_handle(CooperativeSource {
421            observed: observed.clone(),
422        });
423        lifecycle.spawn_from_source("watcher_producer", &source);
424
425        parent.fire();
426
427        let report = lifecycle.shutdown(Duration::from_secs(1)).await;
428        assert_eq!(report.drained, 1);
429        assert_eq!(observed.load(Ordering::SeqCst), 1);
430    }
431
432    #[proxima::test]
433    async fn debug_impl_reports_task_count_and_cancellation_state() {
434        let mut lifecycle = ProducerLifecycle::new();
435        lifecycle.spawn_from_source("dbg_producer", &into_source_handle(NoopSource));
436
437        let pre_shutdown = alloc::format!("{lifecycle:?}");
438        assert!(
439            pre_shutdown.contains("task_count: 1"),
440            "got: {pre_shutdown}"
441        );
442        assert!(pre_shutdown.contains("cancelled: false"));
443
444        let _report = lifecycle.shutdown(Duration::from_secs(1)).await;
445    }
446}