1use 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
40pub struct ProducerLifecycle {
59 tasks: JoinSet<()>,
60 cancel: Signal,
61 spawned_names: Vec<Arc<str>>,
62}
63
64impl ProducerLifecycle {
65 #[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 #[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 #[must_use]
91 pub fn cancel_signal(&self) -> Signal {
92 self.cancel.clone()
93 }
94
95 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 #[must_use]
117 pub fn task_count(&self) -> usize {
118 self.spawned_names.len()
119 }
120
121 pub fn spawned_task_names(&self) -> impl Iterator<Item = &str> {
125 self.spawned_names.iter().map(AsRef::as_ref)
126 }
127
128 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 }
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 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#[derive(Debug, Clone, Copy, Eq, PartialEq)]
207pub struct ShutdownReport {
208 pub total: usize,
210 pub drained: usize,
212 pub aborted: usize,
214 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 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 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 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}