use alloc::sync::Arc;
use alloc::vec::Vec;
use core::time::Duration;
use proxima_core::signal::Signal;
use tracing::{error, trace, warn};
use crate::pipe::SendPipe;
use crate::pipe::source::SourceHandle;
use crate::sync::task::JoinSet;
pub struct ProducerLifecycle {
tasks: JoinSet<()>,
cancel: Signal,
spawned_names: Vec<Arc<str>>,
}
impl ProducerLifecycle {
#[must_use]
pub fn new() -> Self {
Self {
tasks: JoinSet::new(),
cancel: Signal::new(),
spawned_names: Vec::new(),
}
}
#[must_use]
pub fn with_parent_signal(parent: &Signal) -> Self {
Self {
tasks: JoinSet::new(),
cancel: parent.child(),
spawned_names: Vec::new(),
}
}
#[must_use]
pub fn cancel_signal(&self) -> Signal {
self.cancel.clone()
}
pub fn spawn_from_source(&mut self, name: &str, source: &SourceHandle) {
let label: Arc<str> = Arc::from(name);
let cancel = self.cancel.clone();
let source = source.clone();
self.spawned_names.push(label.clone());
self.tasks.spawn(async move {
match SendPipe::call(&source, cancel).await {
Ok(()) => trace!(source = %label, "producer-lifecycle source completed"),
Err(err) => error!(?err, source = %label, "producer-lifecycle source failed"),
}
});
}
#[must_use]
pub fn task_count(&self) -> usize {
self.spawned_names.len()
}
pub fn spawned_task_names(&self) -> impl Iterator<Item = &str> {
self.spawned_names.iter().map(AsRef::as_ref)
}
pub async fn shutdown(mut self, grace: Duration) -> ShutdownReport {
let total = self.spawned_names.len();
self.cancel.fire();
let mut drained = 0usize;
let mut panics = 0usize;
let drain_complete = proxima_core::time::timeout(grace, async {
while let Some(result) = self.tasks.join_next().await {
match result {
Ok(()) => drained += 1,
Err(err) if err.is_cancelled() => {
}
Err(err) if err.is_panic() => {
panics += 1;
error!(?err, "producer-lifecycle task panicked");
}
Err(err) => {
warn!(
?err,
"producer-lifecycle task ended with non-panic, non-cancel JoinError"
);
}
}
}
})
.await;
if drain_complete.is_err() {
self.tasks.abort_all();
while let Some(result) = self.tasks.join_next().await {
if let Err(err) = result
&& err.is_panic()
{
panics += 1;
error!(?err, "producer-lifecycle task panicked during abort");
}
}
}
let aborted = total.saturating_sub(drained);
ShutdownReport {
total,
drained,
aborted,
panics,
}
}
}
impl Default for ProducerLifecycle {
fn default() -> Self {
Self::new()
}
}
impl core::fmt::Debug for ProducerLifecycle {
fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
formatter
.debug_struct("ProducerLifecycle")
.field("task_count", &self.spawned_names.len())
.field("cancelled", &self.cancel.is_fired())
.finish()
}
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub struct ShutdownReport {
pub total: usize,
pub drained: usize,
pub aborted: usize,
pub panics: usize,
}
#[cfg(test)]
mod tests {
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::field_reassign_with_default,
clippy::type_complexity,
clippy::useless_vec,
clippy::needless_range_loop,
clippy::default_constructed_unit_structs
)]
use alloc::sync::Arc;
use core::future::Future;
use core::sync::atomic::{AtomicUsize, Ordering};
use core::time::Duration;
use futures::FutureExt;
use proxima_core::ProximaError;
use crate::pipe::source::{SourceHandle, into_source_handle};
use super::*;
struct NoopSource;
impl SendPipe for NoopSource {
type In = Signal;
type Out = ();
type Err = ProximaError;
fn call(&self, _cancel: Signal) -> impl Future<Output = Result<(), ProximaError>> + Send {
async { Ok(()) }
}
}
struct CooperativeSource {
observed: Arc<AtomicUsize>,
}
impl SendPipe for CooperativeSource {
type In = Signal;
type Out = ();
type Err = ProximaError;
fn call(&self, cancel: Signal) -> impl Future<Output = Result<(), ProximaError>> + Send {
let observed = self.observed.clone();
async move {
cancel.fired().await;
observed.fetch_add(1, Ordering::SeqCst);
Ok(())
}
}
}
struct LongRunningSource;
impl SendPipe for LongRunningSource {
type In = Signal;
type Out = ();
type Err = ProximaError;
fn call(&self, cancel: Signal) -> impl Future<Output = Result<(), ProximaError>> + Send {
async move {
let body = core::future::pending::<()>().fuse();
let cancel_fut = cancel.fired().fuse();
futures::pin_mut!(body, cancel_fut);
futures::select_biased! {
() = cancel_fut => Ok(()),
() = body => Ok(()),
}
}
}
}
struct PanickingSource;
impl SendPipe for PanickingSource {
type In = Signal;
type Out = ();
type Err = ProximaError;
fn call(&self, _cancel: Signal) -> impl Future<Output = Result<(), ProximaError>> + Send {
async { panic!("intentional test panic") }
}
}
#[proxima::test]
async fn empty_lifecycle_shutdown_reports_zero_total() {
let lifecycle = ProducerLifecycle::new();
let report = lifecycle.shutdown(Duration::from_millis(100)).await;
assert_eq!(
report,
ShutdownReport {
total: 0,
drained: 0,
aborted: 0,
panics: 0,
}
);
}
#[proxima::test]
async fn spawned_source_completes_cleanly_drains_within_grace() {
let source: SourceHandle = into_source_handle(NoopSource);
let mut lifecycle = ProducerLifecycle::new();
lifecycle.spawn_from_source("noop_producer", &source);
assert_eq!(lifecycle.task_count(), 1);
let report = lifecycle.shutdown(Duration::from_secs(1)).await;
assert_eq!(report.total, 1);
assert_eq!(report.drained, 1);
assert_eq!(report.aborted, 0);
assert_eq!(report.panics, 0);
}
#[proxima::test]
async fn cooperative_source_respects_cancellation_token_drained_count_one() {
let observed = Arc::new(AtomicUsize::new(0));
let source: SourceHandle = into_source_handle(CooperativeSource {
observed: observed.clone(),
});
let mut lifecycle = ProducerLifecycle::new();
lifecycle.spawn_from_source("cooperating_producer", &source);
let report = lifecycle.shutdown(Duration::from_secs(1)).await;
assert_eq!(report.total, 1);
assert_eq!(report.drained, 1);
assert_eq!(report.aborted, 0);
assert_eq!(observed.load(Ordering::SeqCst), 1);
}
#[proxima::test]
async fn long_running_source_drained_promptly_by_its_own_select_on_cancel() {
let source: SourceHandle = into_source_handle(LongRunningSource);
let mut lifecycle = ProducerLifecycle::new();
lifecycle.spawn_from_source("long_running_producer", &source);
let started = std::time::Instant::now();
let report = lifecycle.shutdown(Duration::from_millis(500)).await;
let elapsed = started.elapsed();
assert!(
elapsed < Duration::from_millis(500),
"shutdown should drain via the source's own select! well before the grace deadline: {elapsed:?}"
);
assert_eq!(report.total, 1);
assert_eq!(
report.drained, 1,
"the source exits cleanly on cancel via its own select!"
);
assert_eq!(report.aborted, 0);
assert_eq!(report.panics, 0);
}
#[proxima::test]
async fn multiple_sources_spawn_and_drain_separately() {
let mut lifecycle = ProducerLifecycle::new();
lifecycle.spawn_from_source("a", &into_source_handle(NoopSource));
lifecycle.spawn_from_source("b", &into_source_handle(NoopSource));
lifecycle.spawn_from_source("c", &into_source_handle(NoopSource));
assert_eq!(lifecycle.task_count(), 3);
let names: Vec<&str> = lifecycle.spawned_task_names().collect();
assert_eq!(names, alloc::vec!["a", "b", "c"]);
let report = lifecycle.shutdown(Duration::from_secs(1)).await;
assert_eq!(report.drained, 3);
}
#[proxima::test]
async fn source_panic_is_counted_but_does_not_crash_lifecycle() {
let mut lifecycle = ProducerLifecycle::new();
lifecycle.spawn_from_source("oops", &into_source_handle(PanickingSource));
lifecycle.spawn_from_source("ok", &into_source_handle(NoopSource));
let report = lifecycle.shutdown(Duration::from_secs(1)).await;
assert_eq!(report.total, 2);
assert_eq!(report.panics, 1, "one source should have panicked");
assert_eq!(report.drained + report.aborted, 2);
}
#[proxima::test]
async fn parent_token_cancellation_propagates_to_child_lifecycle() {
let parent = Signal::new();
let mut lifecycle = ProducerLifecycle::with_parent_signal(&parent);
let observed = Arc::new(AtomicUsize::new(0));
let source: SourceHandle = into_source_handle(CooperativeSource {
observed: observed.clone(),
});
lifecycle.spawn_from_source("watcher_producer", &source);
parent.fire();
let report = lifecycle.shutdown(Duration::from_secs(1)).await;
assert_eq!(report.drained, 1);
assert_eq!(observed.load(Ordering::SeqCst), 1);
}
#[proxima::test]
async fn debug_impl_reports_task_count_and_cancellation_state() {
let mut lifecycle = ProducerLifecycle::new();
lifecycle.spawn_from_source("dbg_producer", &into_source_handle(NoopSource));
let pre_shutdown = alloc::format!("{lifecycle:?}");
assert!(
pre_shutdown.contains("task_count: 1"),
"got: {pre_shutdown}"
);
assert!(pre_shutdown.contains("cancelled: false"));
let _report = lifecycle.shutdown(Duration::from_secs(1)).await;
}
}