zeph_common/task_supervisor.rs
1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Supervised lifecycle task manager for long-running named services.
5//!
6//! [`TaskSupervisor`] manages named, long-lived background tasks (config watcher,
7//! scheduler loop, gateway, MCP connections, etc.) with restart policies, health
8//! snapshots, and graceful shutdown. Unlike `BackgroundSupervisor`
9//! (which is `&mut self`-only, lossy, and turn-scoped), `TaskSupervisor` is
10//! `Clone + Send + Sync` and designed for the full agent session lifetime.
11//!
12//! # Design rationale
13//!
14//! - **Shared handle**: `Arc<Inner>` interior allows passing the supervisor to bootstrap
15//! code, TUI status display, and shutdown orchestration without lifetime coupling.
16//! - **Event-driven reap**: An internal mpsc channel delivers completion events to a
17//! reap driver task; no polling interval required.
18//! - **No `JoinSet`**: Individual `JoinHandle`s per task enable per-name abort, status
19//! tracking, and restart policies — `JoinSet` is better for homogeneous work.
20//! - **Mutex held briefly**: `parking_lot::Mutex` guards only bookkeeping operations
21//! (insert/remove from `HashMap`). The lock is **never held across `.await`**.
22//!
23//! # Examples
24//!
25//! ```rust,no_run
26//! use std::time::Duration;
27//! use tokio_util::sync::CancellationToken;
28//! use zeph_common::task_supervisor::{RestartPolicy, TaskDescriptor, TaskSupervisor};
29//!
30//! # #[tokio::main]
31//! # async fn main() {
32//! let cancel = CancellationToken::new();
33//! let supervisor = TaskSupervisor::new(cancel.clone());
34//!
35//! supervisor.spawn(TaskDescriptor {
36//! name: "my-service",
37//! restart: RestartPolicy::Restart { max: 3, base_delay: Duration::from_secs(1) },
38//! factory: || async { /* service loop */ },
39//! });
40//!
41//! // Graceful shutdown — waits up to 5 s for all tasks to stop.
42//! supervisor.shutdown_all(Duration::from_secs(5)).await;
43//! # }
44//! ```
45
46use std::collections::HashMap;
47use std::future::Future;
48use std::pin::Pin;
49use std::sync::Arc;
50use std::time::{Duration, Instant};
51
52use tokio::sync::{mpsc, oneshot};
53use tokio::task::AbortHandle;
54use tokio_util::sync::CancellationToken;
55use tracing::Instrument as _;
56
57use crate::BlockingSpawner;
58
59// ── Public types ─────────────────────────────────────────────────────────────
60
61/// Policy governing what happens when a supervised task completes or panics.
62///
63/// Used in [`TaskDescriptor`] to configure restart behaviour for a task.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65#[non_exhaustive]
66pub enum RestartPolicy {
67 /// Task runs once; normal completion removes it from the registry.
68 RunOnce,
69 /// Task is restarted **only on panic**, up to `max` times.
70 ///
71 /// Normal completion (the future returns `()`) does **not** trigger a restart.
72 /// The task is removed from the registry on normal exit.
73 ///
74 /// A `max` of `0` means the task is monitored but **never** restarted —
75 /// a panic leaves the entry as `Failed` in the registry for observability.
76 /// Use `RunOnce` when you want the entry removed on completion.
77 ///
78 /// Restart delays follow **exponential backoff**: the delay before attempt `n`
79 /// is `base_delay * 2^(n-1)`, capped at [`MAX_RESTART_DELAY`].
80 ///
81 /// # Examples
82 ///
83 /// ```
84 /// use std::time::Duration;
85 /// use zeph_common::task_supervisor::RestartPolicy;
86 ///
87 /// // Restart up to 3 times with exponential backoff starting at 1 s.
88 /// let policy = RestartPolicy::Restart { max: 3, base_delay: Duration::from_secs(1) };
89 /// ```
90 Restart { max: u32, base_delay: Duration },
91}
92
93/// Maximum delay between restart attempts (caps exponential backoff).
94pub const MAX_RESTART_DELAY: Duration = Duration::from_mins(1);
95
96/// Configuration passed to [`TaskSupervisor::spawn`] to describe a supervised task.
97///
98/// `F` must be `Fn` (not `FnOnce`) to support restarts: the factory is called once on
99/// initial spawn and once per restart attempt.
100pub struct TaskDescriptor<F> {
101 /// Unique name for this task (e.g., `"config-watcher"`, `"scheduler-loop"`).
102 ///
103 /// Names must be `'static` — they are typically compile-time string literals.
104 /// Spawning a task with a name that already exists aborts the prior instance.
105 pub name: &'static str,
106 /// Restart policy applied when the task exits unexpectedly.
107 pub restart: RestartPolicy,
108 /// Factory called to produce a new future. Must be `Fn` for restart support.
109 pub factory: F,
110}
111
112/// Opaque handle to a single supervised task.
113///
114/// Can be used to abort the task by name independently of the supervisor.
115#[derive(Debug, Clone)]
116pub struct TaskHandle {
117 name: &'static str,
118 abort: AbortHandle,
119}
120
121impl TaskHandle {
122 /// Abort the task immediately.
123 pub fn abort(&self) {
124 tracing::debug!(task.name = self.name, "task aborted via handle");
125 self.abort.abort();
126 }
127
128 /// Return the task's name.
129 #[must_use]
130 pub const fn name(&self) -> &'static str {
131 self.name
132 }
133}
134
135/// Error returned by [`BlockingHandle::join`].
136#[derive(Debug, PartialEq, Eq)]
137#[non_exhaustive]
138pub enum BlockingError {
139 /// The task panicked before producing a result.
140 Panicked,
141 /// The supervisor (or the task's abort handle) was dropped before the task completed.
142 SupervisorDropped,
143}
144
145impl std::fmt::Display for BlockingError {
146 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147 match self {
148 Self::Panicked => write!(f, "supervised blocking task panicked"),
149 Self::SupervisorDropped => write!(f, "supervisor dropped before task completed"),
150 }
151 }
152}
153
154impl std::error::Error for BlockingError {}
155
156/// Handle returned by [`TaskSupervisor::spawn_blocking`].
157///
158/// Awaiting [`BlockingHandle::join`] blocks until the OS-thread task produces a
159/// value. Dropping the handle without joining does **not** cancel the task — it
160/// continues to run on the blocking thread pool but the result is discarded.
161///
162/// A panic inside the closure is captured and returned as
163/// [`BlockingError::Panicked`] rather than propagating to the caller.
164pub struct BlockingHandle<R> {
165 rx: oneshot::Receiver<Result<R, BlockingError>>,
166 abort: AbortHandle,
167}
168
169impl<R> BlockingHandle<R> {
170 /// Await the task result.
171 ///
172 /// # Errors
173 ///
174 /// - [`BlockingError::Panicked`] — the task closure panicked.
175 /// - [`BlockingError::SupervisorDropped`] — the task was aborted or the
176 /// supervisor was dropped before a value was produced.
177 pub async fn join(self) -> Result<R, BlockingError> {
178 self.rx
179 .await
180 .unwrap_or(Err(BlockingError::SupervisorDropped))
181 }
182
183 /// Non-blocking poll: return the result if the task has already finished, or `None`
184 /// if it is still running.
185 ///
186 /// This is the `BlockingHandle` equivalent of `FutureExt::now_or_never` on a
187 /// [`tokio::task::JoinHandle`]. Call this inside a synchronous context (e.g., between
188 /// agent turns) to apply a completed background result without blocking.
189 ///
190 /// The handle is consumed on success. If the task is not yet done, the handle
191 /// is returned as `Err(self)` so the caller can re-store it.
192 ///
193 /// # Examples
194 ///
195 /// ```rust,no_run
196 /// # use zeph_common::task_supervisor::{BlockingHandle, BlockingError};
197 /// async fn example(mut handle: BlockingHandle<u32>) {
198 /// // Try to get the result without blocking.
199 /// match handle.try_join() {
200 /// Ok(result) => println!("done: {result:?}"),
201 /// Err(handle) => {
202 /// // Task still running — `handle` is returned for re-storage.
203 /// drop(handle);
204 /// }
205 /// }
206 /// }
207 /// ```
208 ///
209 /// # Errors
210 ///
211 /// Returns `Err(self)` when the task has not yet produced a result (still running).
212 /// The inner `Ok(Err(BlockingError::...))` variants are returned when the task
213 /// panicked or the supervisor was dropped before the task completed.
214 pub fn try_join(mut self) -> Result<Result<R, BlockingError>, Self> {
215 match self.rx.try_recv() {
216 Ok(result) => Ok(result),
217 Err(tokio::sync::oneshot::error::TryRecvError::Empty) => Err(self),
218 Err(tokio::sync::oneshot::error::TryRecvError::Closed) => {
219 Ok(Err(BlockingError::SupervisorDropped))
220 }
221 }
222 }
223
224 /// Abort the underlying task immediately.
225 pub fn abort(&self) {
226 self.abort.abort();
227 }
228
229 /// Non-blocking, non-consuming check for whether the task has finished.
230 ///
231 /// Unlike [`try_join`][Self::try_join], this does not consume `self` or retrieve the
232 /// result — it only reports completion, including a panicked or aborted task. Useful
233 /// for reap sweeps that must detect a task exit even when the task's own
234 /// application-level status channel never got a chance to publish a terminal value
235 /// before exiting (e.g. a panic partway through the task body).
236 #[must_use]
237 pub fn is_finished(&self) -> bool {
238 self.abort.is_finished()
239 }
240}
241
242/// Point-in-time state of a supervised task.
243#[derive(Debug, Clone, PartialEq, Eq)]
244#[non_exhaustive]
245pub enum TaskStatus {
246 /// Task is actively running.
247 Running,
248 /// Task is waiting for the restart delay before the next attempt.
249 Restarting { attempt: u32, max: u32 },
250 /// Task completed normally.
251 Completed,
252 /// Task was force-aborted during shutdown.
253 Aborted,
254 /// Task exhausted all restart attempts and is permanently failed.
255 Failed { reason: String },
256}
257
258/// Point-in-time snapshot of a supervised task, returned by [`TaskSupervisor::snapshot`].
259#[derive(Debug, Clone)]
260/// Observability surface per field:
261///
262/// | Field | tokio-console | Jaeger / OTLP | TUI | `metrics` histogram |
263/// |-------|--------------|--------------|-----|---------------------|
264/// | `name` | span name | span name | task list | label `"task"` |
265/// | `task.wall_time_ms` | — | span field | — | `zeph.task.wall_time_ms` |
266/// | `task.cpu_time_ms` | — | span field | — | `zeph.task.cpu_time_ms` |
267/// | `status` | — | — | task list | — |
268/// | `restart_count` | — | — | task list | — |
269pub struct TaskSnapshot {
270 /// Task name.
271 pub name: Arc<str>,
272 /// Current status.
273 pub status: TaskStatus,
274 /// Instant the task was first spawned.
275 pub started_at: Instant,
276 /// Number of times the task has been restarted.
277 pub restart_count: u32,
278}
279
280// ── Internal types ───────────────────────────────────────────────────────────
281
282// `Output = bool` (not `()`) so `spawn_classified` can report an inner failure through
283// the same internal plumbing `spawn` uses (`true` = success/`Normal`, `false` = `Failed`).
284type BoxFuture = Pin<Box<dyn Future<Output = bool> + Send>>;
285type BoxFactory = Box<dyn Fn() -> BoxFuture + Send + Sync>;
286
287struct TaskEntry {
288 name: Arc<str>,
289 status: TaskStatus,
290 started_at: Instant,
291 restart_count: u32,
292 restart_policy: RestartPolicy,
293 abort_handle: AbortHandle,
294 /// `Some` only for `Restart` policy tasks.
295 factory: Option<BoxFactory>,
296}
297
298/// How a supervised task ended.
299#[derive(Debug, Clone, Copy, PartialEq, Eq)]
300enum CompletionKind {
301 /// Future returned normally.
302 Normal,
303 /// Future returned a value that the caller-supplied classifier (see
304 /// [`TaskSupervisor::spawn_oneshot_classified`]) identified as an inner failure —
305 /// distinct from `Normal` even though the outer `JoinHandle` itself resolved
306 /// successfully (no panic, no cancellation).
307 Failed,
308 /// Future panicked.
309 Panicked,
310 /// Future was cancelled via the cancellation token or abort handle.
311 Cancelled,
312}
313
314struct Completion {
315 name: Arc<str>,
316 kind: CompletionKind,
317}
318
319struct SupervisorState {
320 tasks: HashMap<Arc<str>, TaskEntry>,
321}
322
323struct Inner {
324 state: parking_lot::Mutex<SupervisorState>,
325 /// Completion events from spawned tasks → reap driver.
326 /// Lives in `Inner` (not `SupervisorState`) to avoid double mutex acquisition
327 /// — callers clone it once during spawn without re-locking state.
328 completion_tx: mpsc::UnboundedSender<Completion>,
329 cancel: CancellationToken,
330 /// Limits the number of concurrently running `spawn_blocking` tasks to prevent
331 /// runaway thread-pool growth under burst load.
332 blocking_semaphore: Arc<tokio::sync::Semaphore>,
333 /// Notified when `active_count()` reaches zero so `shutdown_all` wakes immediately.
334 shutdown_notify: Arc<tokio::sync::Notify>,
335}
336
337// ── Main type ────────────────────────────────────────────────────────────────
338
339/// Shared, cloneable handle to the supervised lifecycle task registry.
340///
341/// `TaskSupervisor` manages named, long-lived background tasks with restart
342/// policies, health snapshots, and graceful shutdown. It is `Clone + Send + Sync`
343/// so it can be distributed to bootstrap code, TUI, and shutdown orchestration
344/// without any additional synchronisation.
345///
346/// # Thread safety
347///
348/// Interior state is guarded by a `parking_lot::Mutex`. The lock is **never**
349/// held across `.await` points.
350///
351/// # Examples
352///
353/// ```rust,no_run
354/// use std::time::Duration;
355/// use tokio_util::sync::CancellationToken;
356/// use zeph_common::task_supervisor::{RestartPolicy, TaskDescriptor, TaskSupervisor};
357///
358/// # #[tokio::main]
359/// # async fn main() {
360/// let cancel = CancellationToken::new();
361/// let sup = TaskSupervisor::new(cancel.clone());
362///
363/// let _handle = sup.spawn(TaskDescriptor {
364/// name: "watcher",
365/// restart: RestartPolicy::RunOnce,
366/// factory: || async { tokio::time::sleep(std::time::Duration::from_secs(1)).await },
367/// });
368///
369/// sup.shutdown_all(Duration::from_secs(5)).await;
370/// # }
371/// ```
372#[derive(Clone)]
373pub struct TaskSupervisor {
374 inner: Arc<Inner>,
375}
376
377impl std::fmt::Debug for TaskSupervisor {
378 /// Prints a stable placeholder rather than task internals (factory closures
379 /// inside `TaskEntry` are not `Debug`); callers needing task details should
380 /// use [`TaskSupervisor::snapshot`] instead.
381 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
382 f.debug_struct("TaskSupervisor").finish_non_exhaustive()
383 }
384}
385
386impl TaskSupervisor {
387 /// Create a new supervisor and start its reap driver.
388 ///
389 /// The `cancel` token is propagated into every spawned task via `tokio::select!`.
390 /// When the token is cancelled, all tasks exit cooperatively on their next
391 /// cancellation check. Call [`shutdown_all`][Self::shutdown_all] to wait for
392 /// them to finish.
393 ///
394 /// When called outside a Tokio runtime context (e.g. in synchronous unit tests),
395 /// the reap driver is skipped. The supervisor still accepts task registrations but
396 /// completion callbacks are not processed — safe because no tasks can actually be
397 /// spawned without a runtime.
398 #[must_use]
399 pub fn new(cancel: CancellationToken) -> Self {
400 // NOTE: unbounded channel is acceptable here because supervised tasks are
401 // O(10–20) lifecycle services, not high-throughput work. Backpressure would
402 // complicate the spawn path without practical benefit.
403 let (completion_tx, completion_rx) = mpsc::unbounded_channel();
404 let inner = Arc::new(Inner {
405 state: parking_lot::Mutex::new(SupervisorState {
406 tasks: HashMap::new(),
407 }),
408 completion_tx,
409 cancel: cancel.clone(),
410 blocking_semaphore: Arc::new(tokio::sync::Semaphore::new(8)),
411 shutdown_notify: Arc::new(tokio::sync::Notify::new()),
412 });
413
414 // Only start the reap driver when a Tokio runtime is available. In synchronous
415 // unit tests that construct Agent/LifecycleState directly there is no reactor,
416 // so we skip the spawn. Without a runtime no tasks can be spawned either, so
417 // the driver is not needed.
418 if tokio::runtime::Handle::try_current().is_ok() {
419 Self::start_reap_driver(Arc::clone(&inner), completion_rx, cancel);
420 }
421
422 Self { inner }
423 }
424
425 /// Spawn a named, supervised async task.
426 ///
427 /// If a task with the same `name` already exists, it is aborted before the
428 /// new one is started.
429 ///
430 /// # Examples
431 ///
432 /// ```rust,no_run
433 /// use std::time::Duration;
434 /// use tokio_util::sync::CancellationToken;
435 /// use zeph_common::task_supervisor::{RestartPolicy, TaskDescriptor, TaskHandle, TaskSupervisor};
436 ///
437 /// # #[tokio::main]
438 /// # async fn main() {
439 /// let cancel = CancellationToken::new();
440 /// let sup = TaskSupervisor::new(cancel.clone());
441 ///
442 /// let handle: TaskHandle = sup.spawn(TaskDescriptor {
443 /// name: "config-watcher",
444 /// restart: RestartPolicy::Restart { max: 3, base_delay: Duration::from_secs(1) },
445 /// factory: || async { /* watch loop */ },
446 /// });
447 /// # }
448 /// ```
449 pub fn spawn<F, Fut>(&self, desc: TaskDescriptor<F>) -> TaskHandle
450 where
451 F: Fn() -> Fut + Send + Sync + 'static,
452 Fut: Future<Output = ()> + Send + 'static,
453 {
454 let user_factory = desc.factory;
455 let factory: BoxFactory = Box::new(move || {
456 let fut = user_factory();
457 Box::pin(async move {
458 fut.await;
459 true
460 })
461 });
462 self.spawn_boxed(desc.name, desc.restart, factory)
463 }
464
465 /// Spawn a named, supervised async task like [`spawn`][Self::spawn], but classify the
466 /// produced value's own success/failure via `is_success` — mirrors
467 /// [`spawn_oneshot_classified`][Self::spawn_oneshot_classified] except it supports
468 /// [`RestartPolicy::Restart`] and returns a [`TaskHandle`] (not a typed
469 /// [`BlockingHandle<R>`]), since a restart-capable task's result value cannot be handed
470 /// back to a single caller across restarts — each restart attempt produces a fresh value
471 /// that only the registry's [`TaskStatus`] can observe.
472 ///
473 /// This matters whenever `Fut::Output` itself encodes failure (typically `Result<T, E>`):
474 /// without a classifier, a task that runs to completion but produces `Err(..)` is
475 /// classified and reported identically to one that produced `Ok(..)` — `spawn` alone only
476 /// observes whether the *outer* future resolved, never what value it resolved to.
477 /// `is_success` is called once, by reference, after the future resolves.
478 ///
479 /// # Examples
480 ///
481 /// ```rust,no_run
482 /// use std::time::Duration;
483 /// use tokio_util::sync::CancellationToken;
484 /// use zeph_common::task_supervisor::{RestartPolicy, TaskDescriptor, TaskSupervisor};
485 ///
486 /// # #[tokio::main]
487 /// # async fn main() {
488 /// let sup = TaskSupervisor::new(CancellationToken::new());
489 ///
490 /// let _handle = sup.spawn_classified(
491 /// TaskDescriptor {
492 /// name: "fallible-service",
493 /// restart: RestartPolicy::Restart { max: 0, base_delay: Duration::from_secs(1) },
494 /// factory: || async { Result::<(), String>::Err("boom".to_string()) },
495 /// },
496 /// Result::is_ok,
497 /// );
498 /// # }
499 /// ```
500 pub fn spawn_classified<F, Fut, R>(
501 &self,
502 desc: TaskDescriptor<F>,
503 is_success: impl Fn(&R) -> bool + Send + Sync + 'static,
504 ) -> TaskHandle
505 where
506 F: Fn() -> Fut + Send + Sync + 'static,
507 Fut: Future<Output = R> + Send + 'static,
508 R: Send + 'static,
509 {
510 let user_factory = desc.factory;
511 let is_success = Arc::new(is_success);
512 let factory: BoxFactory = Box::new(move || {
513 let fut = user_factory();
514 let is_success = Arc::clone(&is_success);
515 Box::pin(async move {
516 let val = fut.await;
517 is_success(&val)
518 })
519 });
520 self.spawn_boxed(desc.name, desc.restart, factory)
521 }
522
523 /// Shared registration tail for [`spawn`][Self::spawn] and
524 /// [`spawn_classified`][Self::spawn_classified]: spawns the boxed factory, wires up the
525 /// completion reporter, and inserts the registry entry.
526 fn spawn_boxed(
527 &self,
528 name: &'static str,
529 restart: RestartPolicy,
530 factory: BoxFactory,
531 ) -> TaskHandle {
532 let cancel = self.inner.cancel.clone();
533 let completion_tx = self.inner.completion_tx.clone();
534 let name_arc: Arc<str> = Arc::from(name);
535
536 let (abort_handle, jh) = Self::do_spawn(name, &factory, cancel);
537 Self::wire_completion_reporter(Arc::clone(&name_arc), jh, completion_tx);
538
539 let entry = TaskEntry {
540 name: Arc::clone(&name_arc),
541 status: TaskStatus::Running,
542 started_at: Instant::now(),
543 restart_count: 0,
544 restart_policy: restart,
545 abort_handle: abort_handle.clone(),
546 factory: match restart {
547 RestartPolicy::RunOnce => None,
548 RestartPolicy::Restart { .. } => Some(factory),
549 },
550 };
551
552 {
553 let mut state = self.inner.state.lock();
554 if let Some(old) = state.tasks.remove(&name_arc) {
555 old.abort_handle.abort();
556 }
557 state.tasks.insert(Arc::clone(&name_arc), entry);
558 }
559
560 TaskHandle {
561 name,
562 abort: abort_handle,
563 }
564 }
565
566 /// Spawn a CPU-bound closure on the OS blocking thread pool.
567 ///
568 /// The closure runs via [`tokio::task::spawn_blocking`] — it is never polled
569 /// on tokio worker threads and cannot block async I/O. The task is registered
570 /// in the supervisor registry and is visible to [`snapshot`][Self::snapshot]
571 /// and [`shutdown_all`][Self::shutdown_all].
572 ///
573 /// Dropping the returned [`BlockingHandle`] without calling `.join()` does
574 /// **not** cancel the task; it runs to completion but the result is discarded.
575 ///
576 /// A panic inside `f` is captured and returned as [`BlockingError::Panicked`]
577 /// rather than propagating to the caller.
578 ///
579 /// # Examples
580 ///
581 /// ```rust,no_run
582 /// use std::sync::Arc;
583 /// use tokio_util::sync::CancellationToken;
584 /// use zeph_common::task_supervisor::{BlockingHandle, TaskSupervisor};
585 ///
586 /// # #[tokio::main]
587 /// # async fn main() {
588 /// let cancel = CancellationToken::new();
589 /// let sup = TaskSupervisor::new(cancel);
590 ///
591 /// let handle: BlockingHandle<u32> = sup.spawn_blocking(Arc::from("compute"), || {
592 /// // CPU-bound work — safe to block here
593 /// 42_u32
594 /// });
595 /// let result = handle.join().await.unwrap();
596 /// assert_eq!(result, 42);
597 /// # }
598 /// ```
599 ///
600 /// # Capacity limit
601 ///
602 /// At most 8 `spawn_blocking` tasks run concurrently. Additional tasks wait for a
603 /// semaphore permit, bounding thread-pool growth under burst load.
604 ///
605 /// # Panics
606 ///
607 /// Panics inside `f` are captured and returned as [`BlockingError::Panicked`] — they
608 /// do not propagate to the caller.
609 #[allow(clippy::needless_pass_by_value)] // `name` is cloned into async task and registry
610 pub fn spawn_blocking<F, R>(&self, name: Arc<str>, f: F) -> BlockingHandle<R>
611 where
612 F: FnOnce() -> R + Send + 'static,
613 R: Send + 'static,
614 {
615 let (tx, rx) = oneshot::channel::<Result<R, BlockingError>>();
616 let span = tracing::info_span!(
617 "supervised_blocking_task",
618 task.name = %name,
619 task.wall_time_ms = tracing::field::Empty,
620 task.cpu_time_ms = tracing::field::Empty,
621 );
622
623 let semaphore = Arc::clone(&self.inner.blocking_semaphore);
624 let inner = Arc::clone(&self.inner);
625 let name_clone = Arc::clone(&name);
626 let completion_tx = self.inner.completion_tx.clone();
627
628 // Wrap the blocking spawn in an async task that first acquires a semaphore
629 // permit, bounding the number of concurrently running blocking tasks to 8.
630 let outer = tokio::spawn(async move {
631 let _permit = semaphore
632 .acquire_owned()
633 .await
634 .expect("blocking semaphore closed");
635
636 let name_for_measure = Arc::clone(&name_clone);
637 let join_handle = tokio::task::spawn_blocking(move || {
638 let _enter = span.enter();
639 measure_blocking(&name_for_measure, f)
640 });
641 let abort = join_handle.abort_handle();
642
643 // Update registry with the real abort handle now that spawn_blocking is live.
644 {
645 let mut state = inner.state.lock();
646 if let Some(entry) = state.tasks.get_mut(&name_clone) {
647 entry.abort_handle = abort;
648 }
649 }
650
651 let kind = match join_handle.await {
652 Ok(val) => {
653 let _ = tx.send(Ok(val));
654 CompletionKind::Normal
655 }
656 Err(e) if e.is_panic() => {
657 let _ = tx.send(Err(BlockingError::Panicked));
658 CompletionKind::Panicked
659 }
660 Err(_) => {
661 // Aborted — drop tx so rx returns SupervisorDropped.
662 CompletionKind::Cancelled
663 }
664 };
665 // _permit released here, freeing the semaphore slot.
666 let _ = completion_tx.send(Completion {
667 name: name_clone,
668 kind,
669 });
670 });
671 let abort = outer.abort_handle();
672
673 // Register in registry so snapshot/shutdown sees the task.
674 {
675 let mut state = self.inner.state.lock();
676 if let Some(old) = state.tasks.remove(&name) {
677 old.abort_handle.abort();
678 }
679 state.tasks.insert(
680 Arc::clone(&name),
681 TaskEntry {
682 name: Arc::clone(&name),
683 status: TaskStatus::Running,
684 started_at: Instant::now(),
685 restart_count: 0,
686 restart_policy: RestartPolicy::RunOnce,
687 abort_handle: abort.clone(),
688 factory: None,
689 },
690 );
691 }
692
693 BlockingHandle { rx, abort }
694 }
695
696 /// Spawn an async task that produces a typed result value (runs on tokio worker thread).
697 ///
698 /// Unlike [`spawn`][Self::spawn], no restart policy is supported — the task
699 /// runs once. The task is registered in the supervisor registry under the
700 /// provided `name` and is visible to [`snapshot`][Self::snapshot] and
701 /// [`shutdown_all`][Self::shutdown_all].
702 ///
703 /// For CPU-bound work that must not block tokio workers, use
704 /// [`spawn_blocking`][Self::spawn_blocking] instead.
705 ///
706 /// # Examples
707 ///
708 /// ```rust,no_run
709 /// use std::sync::Arc;
710 /// use tokio_util::sync::CancellationToken;
711 /// use zeph_common::task_supervisor::{BlockingHandle, TaskSupervisor};
712 ///
713 /// # #[tokio::main]
714 /// # async fn main() {
715 /// let cancel = CancellationToken::new();
716 /// let sup = TaskSupervisor::new(cancel.clone());
717 ///
718 /// let handle: BlockingHandle<u32> = sup.spawn_oneshot(Arc::from("compute"), || async { 42_u32 });
719 /// let result = handle.join().await.unwrap();
720 /// assert_eq!(result, 42);
721 /// # }
722 /// ```
723 pub fn spawn_oneshot<F, Fut, R>(&self, name: Arc<str>, factory: F) -> BlockingHandle<R>
724 where
725 F: FnOnce() -> Fut + Send + 'static,
726 Fut: Future<Output = R> + Send + 'static,
727 R: Send + 'static,
728 {
729 self.spawn_oneshot_classified(name, factory, |_| true)
730 }
731
732 /// Spawn an async task like [`spawn_oneshot`][Self::spawn_oneshot], but additionally
733 /// classify the produced value's own success/failure via `is_success` instead of only
734 /// the outer `JoinHandle` outcome (normal exit vs. panic vs. cancellation).
735 ///
736 /// This matters whenever `Fut::Output` itself encodes failure (typically
737 /// `Result<T, E>`): without a classifier, a task that runs to completion but produces
738 /// `Err(..)` is classified and logged identically to one that produced `Ok(..)` —
739 /// `spawn_oneshot` alone only observes whether the *outer* future resolved, never what
740 /// value it resolved to. `is_success` is called once, by reference, before the value is
741 /// sent to the returned [`BlockingHandle`]'s receiver — it never consumes or blocks on
742 /// the value, and the value itself is delivered to the caller unchanged either way.
743 ///
744 /// # Examples
745 ///
746 /// ```rust,no_run
747 /// use std::sync::Arc;
748 /// use tokio_util::sync::CancellationToken;
749 /// use zeph_common::task_supervisor::TaskSupervisor;
750 ///
751 /// # #[tokio::main]
752 /// # async fn main() {
753 /// let sup = TaskSupervisor::new(CancellationToken::new());
754 /// let handle = sup.spawn_oneshot_classified(
755 /// Arc::from("fallible-task"),
756 /// || async { Result::<u32, String>::Err("boom".to_string()) },
757 /// Result::is_ok,
758 /// );
759 /// let result = handle.join().await.unwrap();
760 /// assert!(result.is_err());
761 /// # }
762 /// ```
763 pub fn spawn_oneshot_classified<F, Fut, R>(
764 &self,
765 name: Arc<str>,
766 factory: F,
767 is_success: impl Fn(&R) -> bool + Send + 'static,
768 ) -> BlockingHandle<R>
769 where
770 F: FnOnce() -> Fut + Send + 'static,
771 Fut: Future<Output = R> + Send + 'static,
772 R: Send + 'static,
773 {
774 let (tx, rx) = oneshot::channel::<Result<R, BlockingError>>();
775 let cancel = self.inner.cancel.clone();
776 let span = tracing::info_span!("supervised_task", task.name = %name);
777 let join_handle: tokio::task::JoinHandle<Option<R>> = tokio::spawn(
778 async move {
779 let fut = factory();
780 tokio::select! {
781 result = fut => Some(result),
782 () = cancel.cancelled() => None,
783 }
784 }
785 .instrument(span),
786 );
787 let abort = join_handle.abort_handle();
788
789 {
790 let mut state = self.inner.state.lock();
791 if let Some(old) = state.tasks.remove(&name) {
792 old.abort_handle.abort();
793 }
794 state.tasks.insert(
795 Arc::clone(&name),
796 TaskEntry {
797 name: Arc::clone(&name),
798 status: TaskStatus::Running,
799 started_at: Instant::now(),
800 restart_count: 0,
801 restart_policy: RestartPolicy::RunOnce,
802 abort_handle: abort.clone(),
803 factory: None,
804 },
805 );
806 }
807
808 let completion_tx = self.inner.completion_tx.clone();
809 tokio::spawn(async move {
810 let kind = match join_handle.await {
811 Ok(Some(val)) => {
812 let kind = if is_success(&val) {
813 CompletionKind::Normal
814 } else {
815 CompletionKind::Failed
816 };
817 let _ = tx.send(Ok(val));
818 kind
819 }
820 Err(e) if e.is_panic() => {
821 let _ = tx.send(Err(BlockingError::Panicked));
822 CompletionKind::Panicked
823 }
824 _ => CompletionKind::Cancelled,
825 };
826 let _ = completion_tx.send(Completion { name, kind });
827 });
828 BlockingHandle { rx, abort }
829 }
830
831 /// Abort a task by name. No-op if no task with that name is registered.
832 pub fn abort(&self, name: &'static str) {
833 let state = self.inner.state.lock();
834 let key: Arc<str> = Arc::from(name);
835 if let Some(entry) = state.tasks.get(&key) {
836 entry.abort_handle.abort();
837 tracing::debug!(task.name = name, "task aborted via supervisor");
838 }
839 }
840
841 /// Gracefully shut down all supervised tasks.
842 ///
843 /// Cancels the supervisor's [`CancellationToken`] and waits up to `timeout`
844 /// for all tasks to exit. Tasks that do not exit within the timeout are
845 /// aborted forcefully and their registry entries updated to [`TaskStatus::Aborted`].
846 ///
847 /// # Note
848 ///
849 /// This cancels the token passed to [`TaskSupervisor::new`]. If you share
850 /// that token with other subsystems, they will be cancelled too. Use a child
851 /// token (`cancel.child_token()`) when the supervisor should not affect
852 /// unrelated components.
853 pub async fn shutdown_all(&self, timeout: Duration) {
854 self.inner.cancel.cancel();
855 let sleep = tokio::time::sleep(timeout);
856 tokio::pin!(sleep);
857 loop {
858 let active = self.active_count();
859 if active == 0 {
860 break;
861 }
862 // Subscribe before re-checking so we cannot miss a notification that
863 // fires between the active_count() call above and the select below.
864 let notified = self.inner.shutdown_notify.notified();
865 tokio::select! {
866 biased;
867 () = notified => {
868 // reap driver decremented active count — re-check at top of loop
869 }
870 () = &mut sleep => {
871 let mut remaining_names: Vec<Arc<str>> = Vec::new();
872 {
873 let mut state = self.inner.state.lock();
874 for entry in state.tasks.values_mut() {
875 if matches!(
876 entry.status,
877 TaskStatus::Running | TaskStatus::Restarting { .. }
878 ) {
879 remaining_names.push(Arc::clone(&entry.name));
880 entry.abort_handle.abort();
881 entry.status = TaskStatus::Aborted;
882 }
883 }
884 }
885 tracing::warn!(
886 remaining = active,
887 tasks = ?remaining_names,
888 "shutdown timeout — aborting remaining tasks"
889 );
890 break;
891 }
892 }
893 }
894 }
895
896 /// Return a point-in-time snapshot of all registered tasks.
897 ///
898 /// Suitable for TUI status panels and structured logging. The returned
899 /// list is sorted by `started_at` ascending.
900 #[must_use]
901 pub fn snapshot(&self) -> Vec<TaskSnapshot> {
902 let state = self.inner.state.lock();
903 let mut snaps: Vec<TaskSnapshot> = state
904 .tasks
905 .values()
906 .map(|e| TaskSnapshot {
907 name: Arc::clone(&e.name),
908 status: e.status.clone(),
909 started_at: e.started_at,
910 restart_count: e.restart_count,
911 })
912 .collect();
913 snaps.sort_by_key(|s| s.started_at);
914 snaps
915 }
916
917 /// Return the number of tasks currently in `Running` or `Restarting` state.
918 #[must_use]
919 pub fn active_count(&self) -> usize {
920 let state = self.inner.state.lock();
921 state
922 .tasks
923 .values()
924 .filter(|e| {
925 matches!(
926 e.status,
927 TaskStatus::Running | TaskStatus::Restarting { .. }
928 )
929 })
930 .count()
931 }
932
933 /// Return a clone of the supervisor's [`CancellationToken`].
934 ///
935 /// Callers can use this to check whether shutdown has been initiated.
936 #[must_use]
937 pub fn cancellation_token(&self) -> CancellationToken {
938 self.inner.cancel.clone()
939 }
940
941 // ── Internal helpers ──────────────────────────────────────────────────────
942
943 /// Spawn the actual tokio task. Returns `(AbortHandle, JoinHandle)`.
944 fn do_spawn(
945 name: &'static str,
946 factory: &BoxFactory,
947 cancel: CancellationToken,
948 ) -> (AbortHandle, tokio::task::JoinHandle<bool>) {
949 let fut = factory();
950 let span = tracing::info_span!("supervised_task", task.name = name);
951 let jh = tokio::spawn(
952 async move {
953 tokio::select! {
954 ok = fut => ok,
955 // Token-cancelled = graceful exit, not a failure.
956 () = cancel.cancelled() => true,
957 }
958 }
959 .instrument(span),
960 );
961 let abort = jh.abort_handle();
962 (abort, jh)
963 }
964
965 /// Wire a completion reporter: drives `jh` and sends the result to `completion_tx`.
966 fn wire_completion_reporter(
967 name: Arc<str>,
968 jh: tokio::task::JoinHandle<bool>,
969 completion_tx: mpsc::UnboundedSender<Completion>,
970 ) {
971 tokio::spawn(async move {
972 let kind = match jh.await {
973 Ok(true) => CompletionKind::Normal,
974 Ok(false) => CompletionKind::Failed,
975 Err(e) if e.is_panic() => CompletionKind::Panicked,
976 Err(_) => CompletionKind::Cancelled,
977 };
978 let _ = completion_tx.send(Completion { name, kind });
979 });
980 }
981
982 /// Spawn the reap driver. The driver processes completion events from the mpsc channel.
983 ///
984 /// After the cancellation token fires, the driver continues draining the channel
985 /// until the registry reports no active tasks (or the channel closes) — this
986 /// ensures that tasks which complete after cancel, however long that takes,
987 /// still have their registry entries updated, allowing `shutdown_all` to observe
988 /// `active_count() == 0` correctly and wake early. The driver enforces no
989 /// deadline of its own: [`TaskSupervisor::shutdown_all`]'s own `sleep(timeout)`
990 /// is the sole authority for giving up on a stuck task, regardless of how long
991 /// the gap is between the token being cancelled (typically out-of-band, by a
992 /// shutdown bridge or signal handler — see `src/runner.rs`, `src/serve/mod.rs`)
993 /// and `shutdown_all` actually being invoked. See the Phase 2 comment below for
994 /// why this is correct and terminates.
995 fn start_reap_driver(
996 inner: Arc<Inner>,
997 mut completion_rx: mpsc::UnboundedReceiver<Completion>,
998 cancel: CancellationToken,
999 ) {
1000 tokio::spawn(async move {
1001 // Phase 1: normal operation — process completions until cancel fires.
1002 loop {
1003 tokio::select! {
1004 biased;
1005 Some(completion) = completion_rx.recv() => {
1006 Self::handle_completion(&inner, completion).await;
1007 }
1008 () = cancel.cancelled() => break,
1009 }
1010 }
1011
1012 // Phase 2: post-cancel drain — keep receiving completions until the
1013 // registry reports no active tasks, or the channel closes. This prevents
1014 // losing completions that arrive after tasks observe cancellation
1015 // (#3161), for however long that takes.
1016 //
1017 // Deliberately no independent deadline here (#5926): an earlier version
1018 // of this drain phase enforced its own short fallback timeout, which
1019 // could — and at every real call site, reliably did — expire before
1020 // `shutdown_all` was ever invoked (the shared `CancellationToken` is
1021 // cancelled out-of-band by a shutdown bridge/signal handler well before
1022 // `shutdown_all` runs; see `src/runner.rs`, `src/serve/mod.rs`), causing
1023 // the driver to exit and silently drop any later completion. There is
1024 // exactly one deadline authority for the whole shutdown sequence:
1025 // `shutdown_all`'s own `sleep(timeout)` (below), which force-aborts any
1026 // task still `Running`/`Restarting` under the registry lock directly —
1027 // that abort itself generates a `Cancelled` completion for `spawn`/
1028 // `spawn_oneshot` tasks via `wire_completion_reporter`, waking this loop
1029 // promptly. The one exception is a `spawn_blocking` task force-aborted
1030 // while its outer wrapper is still parked on the concurrency semaphore
1031 // (see `spawn_blocking`, below) — no completion is sent in that case, but
1032 // `shutdown_all` has already marked the entry `Aborted` directly under the
1033 // state lock and returned correctly; only this now-idle reap-driver task
1034 // may linger parked until process/test teardown, which is not a hang. This
1035 // task holds its own `Arc<Inner>` (which also owns a `completion_tx`
1036 // clone) for as long as it runs, so `completion_rx.recv()` can only
1037 // resolve via a real completion or the channel genuinely closing — never a
1038 // stale timeout.
1039 let active = Self::has_active_tasks(&inner);
1040 tracing::debug!(active, "reap driver entered post-cancel drain phase");
1041 loop {
1042 if !Self::has_active_tasks(&inner) {
1043 break;
1044 }
1045 match completion_rx.recv().await {
1046 Some(completion) => Self::handle_completion(&inner, completion).await,
1047 None => break, // channel closed — unreachable in practice
1048 }
1049 }
1050 tracing::debug!(
1051 active = Self::has_active_tasks(&inner),
1052 "reap driver drain phase complete"
1053 );
1054 });
1055 }
1056
1057 /// Returns `true` if any task is in `Running` or `Restarting` state.
1058 fn has_active_tasks(inner: &Arc<Inner>) -> bool {
1059 let state = inner.state.lock();
1060 state.tasks.values().any(|e| {
1061 matches!(
1062 e.status,
1063 TaskStatus::Running | TaskStatus::Restarting { .. }
1064 )
1065 })
1066 }
1067
1068 /// Process a single task completion event.
1069 ///
1070 /// Lock is never held across `.await`. Phase 1 classifies the completion
1071 /// under lock; Phase 2 sleeps with exponential backoff without a lock;
1072 /// Phase 3 spawns the next instance and updates the registry.
1073 async fn handle_completion(inner: &Arc<Inner>, completion: Completion) {
1074 // Short-circuit: once cancellation has fired, never schedule restarts.
1075 // Without this, Restart-policy tasks re-register as Running, causing
1076 // has_active_tasks() to stay true and the drain loop to spin until timeout.
1077 if inner.cancel.is_cancelled() {
1078 {
1079 let mut state = inner.state.lock();
1080 state.tasks.remove(&completion.name);
1081 }
1082 inner.shutdown_notify.notify_waiters();
1083 return;
1084 }
1085
1086 let Some((attempt, max, delay)) = Self::classify_completion(inner, &completion) else {
1087 // Task removed from registry (RunOnce completed or Restart exhausted) —
1088 // wake shutdown_all so it can re-check active_count immediately.
1089 inner.shutdown_notify.notify_waiters();
1090 return;
1091 };
1092
1093 tracing::warn!(
1094 task.name = %completion.name,
1095 attempt,
1096 max,
1097 delay_ms = delay.as_millis(),
1098 "restarting supervised task"
1099 );
1100
1101 if !delay.is_zero() {
1102 tokio::time::sleep(delay).await;
1103 }
1104
1105 Self::do_restart(inner, &completion.name, attempt);
1106 }
1107
1108 /// Phase 1: classify the completion under lock and return restart parameters if needed.
1109 ///
1110 /// Returns `Some((attempt, max, backoff_delay))` when a restart should be scheduled.
1111 fn classify_completion(
1112 inner: &Arc<Inner>,
1113 completion: &Completion,
1114 ) -> Option<(u32, u32, Duration)> {
1115 let mut state = inner.state.lock();
1116 let entry = state.tasks.get_mut(&completion.name)?;
1117
1118 match completion.kind {
1119 CompletionKind::Panicked => {
1120 tracing::warn!(task.name = %completion.name, "supervised task panicked");
1121 }
1122 CompletionKind::Failed => {
1123 tracing::warn!(
1124 task.name = %completion.name,
1125 "supervised task completed with an inner failure"
1126 );
1127 }
1128 CompletionKind::Normal => {
1129 tracing::info!(task.name = %completion.name, "supervised task completed");
1130 }
1131 CompletionKind::Cancelled => {
1132 tracing::debug!(task.name = %completion.name, "supervised task cancelled");
1133 }
1134 }
1135
1136 match entry.restart_policy {
1137 RestartPolicy::RunOnce => {
1138 if completion.kind == CompletionKind::Failed {
1139 entry.status = TaskStatus::Failed {
1140 reason: "task completed with an inner failure".to_string(),
1141 };
1142 } else {
1143 entry.status = TaskStatus::Completed;
1144 }
1145 state.tasks.remove(&completion.name);
1146 None
1147 }
1148 RestartPolicy::Restart { max, base_delay } => {
1149 // Only *restart* on panic — normal exit, an inner failure, and cancellation
1150 // are not retried. An inner failure is retained (not removed) as a durable
1151 // Failed entry so it stays visible in snapshot()/TUI, mirroring the
1152 // panic-exhausted path below — restart-policy tasks use fixed, bounded
1153 // `&'static str` names, so retention here cannot leak (#6510).
1154 if completion.kind != CompletionKind::Panicked {
1155 if completion.kind == CompletionKind::Failed {
1156 entry.status = TaskStatus::Failed {
1157 reason: "task completed with an inner failure".to_string(),
1158 };
1159 } else {
1160 entry.status = TaskStatus::Completed;
1161 state.tasks.remove(&completion.name);
1162 }
1163 return None;
1164 }
1165 if entry.restart_count >= max {
1166 let reason = format!("panicked after {max} restart(s)");
1167 tracing::error!(
1168 task.name = %completion.name,
1169 attempts = max,
1170 "task failed permanently"
1171 );
1172 entry.status = TaskStatus::Failed { reason };
1173 None
1174 } else {
1175 let attempt = entry.restart_count + 1;
1176 entry.status = TaskStatus::Restarting { attempt, max };
1177 // Exponential backoff: base_delay * 2^(attempt-1), capped at MAX_RESTART_DELAY.
1178 let multiplier = 1_u32
1179 .checked_shl(attempt.saturating_sub(1))
1180 .unwrap_or(u32::MAX);
1181 let delay = base_delay.saturating_mul(multiplier).min(MAX_RESTART_DELAY);
1182 Some((attempt, max, delay))
1183 }
1184 }
1185 }
1186 // lock released here
1187 }
1188
1189 /// Phase 3: TOCTOU check, collect spawn params under lock, then spawn outside.
1190 fn do_restart(inner: &Arc<Inner>, name: &Arc<str>, attempt: u32) {
1191 let spawn_params = {
1192 let mut state = inner.state.lock();
1193 let Some(entry) = state.tasks.get_mut(name.as_ref()) else {
1194 tracing::debug!(
1195 task.name = %name,
1196 "task removed during restart delay — skipping"
1197 );
1198 return;
1199 };
1200 if !matches!(entry.status, TaskStatus::Restarting { .. }) {
1201 return;
1202 }
1203 let Some(factory) = &entry.factory else {
1204 return;
1205 };
1206 // Wrap factory() in catch_unwind to prevent a factory panic from crashing
1207 // the reap driver and orphaning the registry.
1208 match std::panic::catch_unwind(std::panic::AssertUnwindSafe(factory)) {
1209 Err(_) => {
1210 let reason = format!("factory panicked on restart attempt {attempt}");
1211 tracing::error!(task.name = %name, attempt, "factory panicked during restart");
1212 entry.status = TaskStatus::Failed { reason };
1213 None
1214 }
1215 Ok(fut) => Some((
1216 fut,
1217 inner.cancel.clone(),
1218 inner.completion_tx.clone(),
1219 name.clone(),
1220 )),
1221 }
1222 // lock released here
1223 };
1224
1225 let Some((fut, cancel, completion_tx, name)) = spawn_params else {
1226 return;
1227 };
1228
1229 let span = tracing::info_span!("supervised_task", task.name = %name);
1230 let jh = tokio::spawn(
1231 async move {
1232 tokio::select! {
1233 ok = fut => ok,
1234 // Token-cancelled = graceful exit, not a failure.
1235 () = cancel.cancelled() => true,
1236 }
1237 }
1238 .instrument(span),
1239 );
1240 let new_abort = jh.abort_handle();
1241
1242 {
1243 let mut state = inner.state.lock();
1244 if let Some(entry) = state.tasks.get_mut(name.as_ref()) {
1245 entry.restart_count = attempt;
1246 entry.status = TaskStatus::Running;
1247 entry.abort_handle = new_abort;
1248 }
1249 }
1250
1251 Self::wire_completion_reporter(name, jh, completion_tx);
1252 }
1253}
1254
1255// ── Task metrics helpers ──────────────────────────────────────────────────────
1256
1257/// Run `f` and record wall-time and CPU-time metrics via `metrics` crate.
1258#[inline]
1259fn measure_blocking<F, R>(name: &str, f: F) -> R
1260where
1261 F: FnOnce() -> R,
1262{
1263 use cpu_time::ThreadTime;
1264 let wall_start = std::time::Instant::now();
1265 let cpu_start = ThreadTime::now();
1266 let result = f();
1267 let wall_ms = wall_start.elapsed().as_secs_f64() * 1000.0;
1268 let cpu_ms = cpu_start.elapsed().as_secs_f64() * 1000.0;
1269 metrics::histogram!("zeph.task.wall_time_ms", "task" => name.to_owned()).record(wall_ms);
1270 metrics::histogram!("zeph.task.cpu_time_ms", "task" => name.to_owned()).record(cpu_ms);
1271 tracing::Span::current().record("task.wall_time_ms", wall_ms);
1272 tracing::Span::current().record("task.cpu_time_ms", cpu_ms);
1273 result
1274}
1275
1276// ── BlockingSpawner impl ──────────────────────────────────────────────────────
1277
1278impl BlockingSpawner for TaskSupervisor {
1279 /// Spawn a named blocking closure through the supervisor.
1280 ///
1281 /// The task is registered in the supervisor registry (visible in
1282 /// [`snapshot`][Self::snapshot] and subject to graceful shutdown) before
1283 /// the closure begins executing.
1284 fn spawn_blocking_named(
1285 &self,
1286 name: Arc<str>,
1287 f: Box<dyn FnOnce() + Send + 'static>,
1288 ) -> tokio::task::JoinHandle<()> {
1289 let handle = self.spawn_blocking(Arc::clone(&name), f);
1290 tokio::spawn(async move {
1291 if let Err(e) = handle.join().await {
1292 tracing::error!(task.name = %name, error = %e, "supervised blocking task failed");
1293 }
1294 })
1295 }
1296}
1297
1298// ── Unit tests ────────────────────────────────────────────────────────────────
1299
1300#[cfg(test)]
1301mod tests {
1302 use std::sync::Arc;
1303 use std::sync::atomic::{AtomicU32, Ordering};
1304 use std::time::Duration;
1305
1306 use tokio_util::sync::CancellationToken;
1307
1308 use super::*;
1309
1310 fn make_supervisor() -> (TaskSupervisor, CancellationToken) {
1311 let cancel = CancellationToken::new();
1312 let sup = TaskSupervisor::new(cancel.clone());
1313 (sup, cancel)
1314 }
1315
1316 #[tokio::test]
1317 async fn test_spawn_and_complete() {
1318 let (sup, _cancel) = make_supervisor();
1319
1320 let done = Arc::new(tokio::sync::Notify::new());
1321 let done2 = Arc::clone(&done);
1322
1323 sup.spawn(TaskDescriptor {
1324 name: "simple",
1325 restart: RestartPolicy::RunOnce,
1326 factory: move || {
1327 let d = Arc::clone(&done2);
1328 async move {
1329 d.notify_one();
1330 }
1331 },
1332 });
1333
1334 tokio::time::timeout(Duration::from_secs(2), done.notified())
1335 .await
1336 .expect("task should complete");
1337
1338 tokio::time::sleep(Duration::from_millis(50)).await;
1339 assert_eq!(
1340 sup.active_count(),
1341 0,
1342 "RunOnce task should be removed after completion"
1343 );
1344 }
1345
1346 #[tokio::test]
1347 async fn test_panic_capture() {
1348 let (sup, _cancel) = make_supervisor();
1349
1350 sup.spawn(TaskDescriptor {
1351 name: "panicking",
1352 restart: RestartPolicy::RunOnce,
1353 factory: || async { panic!("intentional test panic") },
1354 });
1355
1356 tokio::time::sleep(Duration::from_millis(200)).await;
1357
1358 let snaps = sup.snapshot();
1359 assert!(
1360 snaps.iter().all(|s| s.name.as_ref() != "panicking"),
1361 "entry should be reaped"
1362 );
1363 assert_eq!(
1364 sup.active_count(),
1365 0,
1366 "active count must be 0 after RunOnce panic"
1367 );
1368 }
1369
1370 /// Regression test for S2: Restart-policy tasks must only restart on panic,
1371 /// not on normal completion.
1372 #[tokio::test]
1373 async fn test_restart_only_on_panic() {
1374 let (sup, _cancel) = make_supervisor();
1375
1376 // Part 1: normal completion — must NOT restart.
1377 let normal_counter = Arc::new(AtomicU32::new(0));
1378 let nc = Arc::clone(&normal_counter);
1379 sup.spawn(TaskDescriptor {
1380 name: "normal-exit",
1381 restart: RestartPolicy::Restart {
1382 max: 3,
1383 base_delay: Duration::from_millis(10),
1384 },
1385 factory: move || {
1386 let c = Arc::clone(&nc);
1387 async move {
1388 c.fetch_add(1, Ordering::SeqCst);
1389 // Returns normally — no panic.
1390 }
1391 },
1392 });
1393
1394 tokio::time::sleep(Duration::from_millis(300)).await;
1395 assert_eq!(
1396 normal_counter.load(Ordering::SeqCst),
1397 1,
1398 "normal exit must not restart"
1399 );
1400 assert!(
1401 sup.snapshot()
1402 .iter()
1403 .all(|s| s.name.as_ref() != "normal-exit"),
1404 "entry removed after normal exit"
1405 );
1406
1407 // Part 2: panic — MUST restart up to max times.
1408 let panic_counter = Arc::new(AtomicU32::new(0));
1409 let pc = Arc::clone(&panic_counter);
1410 sup.spawn(TaskDescriptor {
1411 name: "panic-exit",
1412 restart: RestartPolicy::Restart {
1413 max: 2,
1414 base_delay: Duration::from_millis(10),
1415 },
1416 factory: move || {
1417 let c = Arc::clone(&pc);
1418 async move {
1419 c.fetch_add(1, Ordering::SeqCst);
1420 panic!("test panic");
1421 }
1422 },
1423 });
1424
1425 // initial + 2 restarts = 3 total
1426 tokio::time::sleep(Duration::from_millis(500)).await;
1427 assert!(
1428 panic_counter.load(Ordering::SeqCst) >= 3,
1429 "panicking task must restart max times"
1430 );
1431 let snap = sup
1432 .snapshot()
1433 .into_iter()
1434 .find(|s| s.name.as_ref() == "panic-exit");
1435 assert!(
1436 matches!(snap.unwrap().status, TaskStatus::Failed { .. }),
1437 "task must be Failed after exhausting restarts"
1438 );
1439 }
1440
1441 #[tokio::test]
1442 async fn test_restart_policy() {
1443 let (sup, _cancel) = make_supervisor();
1444
1445 let counter = Arc::new(AtomicU32::new(0));
1446 let counter2 = Arc::clone(&counter);
1447
1448 sup.spawn(TaskDescriptor {
1449 name: "restartable",
1450 restart: RestartPolicy::Restart {
1451 max: 2,
1452 base_delay: Duration::from_millis(10),
1453 },
1454 factory: move || {
1455 let c = Arc::clone(&counter2);
1456 async move {
1457 c.fetch_add(1, Ordering::SeqCst);
1458 panic!("always panic");
1459 }
1460 },
1461 });
1462
1463 tokio::time::sleep(Duration::from_millis(500)).await;
1464
1465 let runs = counter.load(Ordering::SeqCst);
1466 assert!(
1467 runs >= 3,
1468 "expected at least 3 invocations (initial + 2 restarts), got {runs}"
1469 );
1470
1471 let snaps = sup.snapshot();
1472 let snap = snaps.iter().find(|s| s.name.as_ref() == "restartable");
1473 assert!(snap.is_some(), "failed task should remain in registry");
1474 assert!(
1475 matches!(snap.unwrap().status, TaskStatus::Failed { .. }),
1476 "task should be Failed after exhausting retries"
1477 );
1478 }
1479
1480 /// Verify exponential backoff: delay doubles on each restart attempt.
1481 #[tokio::test]
1482 async fn test_exponential_backoff() {
1483 let (sup, _cancel) = make_supervisor();
1484
1485 let timestamps = Arc::new(parking_lot::Mutex::new(Vec::<std::time::Instant>::new()));
1486 let ts = Arc::clone(×tamps);
1487
1488 sup.spawn(TaskDescriptor {
1489 name: "backoff-task",
1490 restart: RestartPolicy::Restart {
1491 max: 3,
1492 base_delay: Duration::from_millis(50),
1493 },
1494 factory: move || {
1495 let t = Arc::clone(&ts);
1496 async move {
1497 t.lock().push(std::time::Instant::now());
1498 panic!("always panic");
1499 }
1500 },
1501 });
1502
1503 // Wait long enough for all restarts: 50 + 100 + 200 ms = 350 ms + overhead
1504 tokio::time::sleep(Duration::from_millis(800)).await;
1505
1506 let ts = timestamps.lock();
1507 assert!(
1508 ts.len() >= 3,
1509 "expected at least 3 invocations, got {}",
1510 ts.len()
1511 );
1512
1513 // Verify delays are roughly doubling (within 2x tolerance for CI jitter).
1514 if ts.len() >= 3 {
1515 let d1 = ts[1].duration_since(ts[0]);
1516 let d2 = ts[2].duration_since(ts[1]);
1517 // d2 should be at least 1.5x d1 (allowing for jitter).
1518 assert!(
1519 d2 >= d1.mul_f64(1.5),
1520 "expected exponential backoff: d1={d1:?} d2={d2:?}"
1521 );
1522 }
1523 }
1524
1525 #[tokio::test]
1526 async fn test_graceful_shutdown() {
1527 let (sup, _cancel) = make_supervisor();
1528
1529 for name in ["svc-a", "svc-b", "svc-c"] {
1530 sup.spawn(TaskDescriptor {
1531 name,
1532 restart: RestartPolicy::RunOnce,
1533 factory: || async {
1534 tokio::time::sleep(Duration::from_mins(1)).await;
1535 },
1536 });
1537 }
1538
1539 assert_eq!(sup.active_count(), 3);
1540
1541 tokio::time::timeout(
1542 Duration::from_secs(2),
1543 sup.shutdown_all(Duration::from_secs(1)),
1544 )
1545 .await
1546 .expect("shutdown should complete within timeout");
1547 }
1548
1549 /// Verify that force-aborted tasks get `TaskStatus::Aborted` in the registry (A2 fix).
1550 #[tokio::test]
1551 async fn test_force_abort_marks_aborted() {
1552 let cancel = CancellationToken::new();
1553 let sup = TaskSupervisor::new(cancel.clone());
1554
1555 sup.spawn(TaskDescriptor {
1556 name: "stubborn-for-abort",
1557 restart: RestartPolicy::RunOnce,
1558 factory: || async {
1559 // Does not cooperate with cancellation.
1560 std::future::pending::<()>().await;
1561 },
1562 });
1563
1564 // Use a very short timeout to trigger force-abort.
1565 sup.shutdown_all(Duration::from_millis(1)).await;
1566
1567 // Entry should be Aborted, not Running.
1568 let snaps = sup.snapshot();
1569 if let Some(snap) = snaps
1570 .iter()
1571 .find(|s| s.name.as_ref() == "stubborn-for-abort")
1572 {
1573 assert_eq!(
1574 snap.status,
1575 TaskStatus::Aborted,
1576 "force-aborted task must have Aborted status"
1577 );
1578 }
1579 // If entry was already reaped (cooperative cancel won), that's also acceptable.
1580 }
1581
1582 #[tokio::test]
1583 async fn test_registry_snapshot() {
1584 let (sup, _cancel) = make_supervisor();
1585
1586 for name in ["alpha", "beta"] {
1587 sup.spawn(TaskDescriptor {
1588 name,
1589 restart: RestartPolicy::RunOnce,
1590 factory: || async {
1591 tokio::time::sleep(Duration::from_secs(10)).await;
1592 },
1593 });
1594 }
1595
1596 let snaps = sup.snapshot();
1597 assert_eq!(snaps.len(), 2);
1598 let names: Vec<&str> = snaps.iter().map(|s| s.name.as_ref()).collect();
1599 assert!(names.contains(&"alpha"));
1600 assert!(names.contains(&"beta"));
1601 assert!(snaps.iter().all(|s| s.status == TaskStatus::Running));
1602 }
1603
1604 #[tokio::test]
1605 async fn test_blocking_returns_value() {
1606 let (sup, cancel) = make_supervisor();
1607
1608 let handle: BlockingHandle<u32> = sup.spawn_blocking(Arc::from("compute"), || 42_u32);
1609 let result = handle.join().await.expect("should return value");
1610 assert_eq!(result, 42);
1611 cancel.cancel();
1612 }
1613
1614 #[tokio::test]
1615 async fn test_blocking_panic() {
1616 let (sup, _cancel) = make_supervisor();
1617
1618 let handle: BlockingHandle<u32> =
1619 sup.spawn_blocking(Arc::from("panicking-compute"), || panic!("intentional"));
1620 let err = handle
1621 .join()
1622 .await
1623 .expect_err("should return error on panic");
1624 assert_eq!(err, BlockingError::Panicked);
1625 }
1626
1627 /// Verify `spawn_blocking` tasks appear in registry (M3 fix).
1628 #[tokio::test]
1629 async fn test_blocking_registered_in_registry() {
1630 let (sup, cancel) = make_supervisor();
1631
1632 let (tx, rx) = std::sync::mpsc::channel::<()>();
1633 let _handle: BlockingHandle<()> =
1634 sup.spawn_blocking(Arc::from("blocking-task"), move || {
1635 // Block until signalled.
1636 let _ = rx.recv();
1637 });
1638
1639 tokio::time::sleep(Duration::from_millis(10)).await;
1640 assert_eq!(
1641 sup.active_count(),
1642 1,
1643 "blocking task must appear in active_count"
1644 );
1645
1646 let _ = tx.send(());
1647 tokio::time::sleep(Duration::from_millis(100)).await;
1648 assert_eq!(
1649 sup.active_count(),
1650 0,
1651 "blocking task must be removed after completion"
1652 );
1653
1654 cancel.cancel();
1655 }
1656
1657 /// Verify `spawn_oneshot` tasks appear in registry (M3 fix).
1658 #[tokio::test]
1659 async fn test_oneshot_registered_in_registry() {
1660 let (sup, cancel) = make_supervisor();
1661
1662 let (tx, rx) = tokio::sync::oneshot::channel::<()>();
1663 let _handle: BlockingHandle<()> =
1664 sup.spawn_oneshot(Arc::from("oneshot-task"), move || async move {
1665 let _ = rx.await;
1666 });
1667
1668 tokio::time::sleep(Duration::from_millis(10)).await;
1669 assert_eq!(
1670 sup.active_count(),
1671 1,
1672 "oneshot task must appear in active_count"
1673 );
1674
1675 let _ = tx.send(());
1676 tokio::time::sleep(Duration::from_millis(50)).await;
1677 assert_eq!(
1678 sup.active_count(),
1679 0,
1680 "oneshot task must be removed after completion"
1681 );
1682
1683 cancel.cancel();
1684 }
1685
1686 /// `spawn_oneshot_classified` must deliver the factory's actual return value to the
1687 /// caller unchanged, regardless of how `is_success` classifies it — the classifier only
1688 /// affects internal registry/log bookkeeping, never the value received via `join()`.
1689 #[tokio::test]
1690 async fn test_oneshot_classified_delivers_actual_value_on_success_and_failure() {
1691 let (sup, _cancel) = make_supervisor();
1692
1693 let ok_handle = sup.spawn_oneshot_classified(
1694 Arc::from("classified-ok"),
1695 || async { Result::<u32, String>::Ok(7) },
1696 Result::is_ok,
1697 );
1698 assert_eq!(ok_handle.join().await.unwrap(), Ok(7));
1699
1700 let err_handle = sup.spawn_oneshot_classified(
1701 Arc::from("classified-err"),
1702 || async { Result::<u32, String>::Err("boom".to_string()) },
1703 Result::is_ok,
1704 );
1705 assert_eq!(
1706 err_handle.join().await.unwrap(),
1707 Err("boom".to_string()),
1708 "the real Err value must reach the caller unchanged even though it is \
1709 classified as CompletionKind::Failed internally"
1710 );
1711 }
1712
1713 /// Regression test for #6257: an `Err`-returning factory must be classified as
1714 /// `CompletionKind::Failed` (logged at `warn` as "completed with an inner failure"),
1715 /// not `CompletionKind::Normal` (logged at `info` as a plain completion). This is the
1716 /// distinction `spawn_agent_task` (zeph-subagent) relies on so a subagent task that
1717 /// returns `Err` after a genuine completion (e.g. a worktree-setup failure) is reported
1718 /// as a supervisor-level failure instead of a normal completion. The registry entry is
1719 /// set to `Failed` and removed in the same lock acquisition, so there is no window in
1720 /// which `snapshot()` could observe the transient status — this test only checks the
1721 /// log line.
1722 #[tokio::test]
1723 #[tracing_test::traced_test]
1724 async fn test_oneshot_classified_logs_failed_for_inner_err() {
1725 let (sup, _cancel) = make_supervisor();
1726
1727 let handle = sup.spawn_oneshot_classified(
1728 Arc::from("classified-inner-err"),
1729 || async { Result::<u32, String>::Err("boom".to_string()) },
1730 Result::is_ok,
1731 );
1732 handle.join().await.unwrap().unwrap_err();
1733
1734 // Give the reap driver a moment to process the completion event.
1735 tokio::time::sleep(Duration::from_millis(50)).await;
1736
1737 assert!(
1738 logs_contain("completed with an inner failure"),
1739 "an Err value must be classified as CompletionKind::Failed, not Normal"
1740 );
1741 }
1742
1743 /// Counterpart to the above: an `Ok`-returning factory must be classified as
1744 /// `CompletionKind::Normal` (logged at `info`), never as an inner failure — confirms
1745 /// the classifier doesn't over-fire on the success path.
1746 #[tokio::test]
1747 #[tracing_test::traced_test]
1748 async fn test_oneshot_classified_logs_normal_for_inner_ok() {
1749 let (sup, _cancel) = make_supervisor();
1750
1751 let handle = sup.spawn_oneshot_classified(
1752 Arc::from("classified-inner-ok"),
1753 || async { Result::<u32, String>::Ok(1) },
1754 Result::is_ok,
1755 );
1756 handle.join().await.unwrap().unwrap();
1757
1758 tokio::time::sleep(Duration::from_millis(50)).await;
1759
1760 assert!(
1761 !logs_contain("completed with an inner failure"),
1762 "an Ok value must not be classified as CompletionKind::Failed"
1763 );
1764 }
1765
1766 /// Regression guard for #6257: `spawn_oneshot`'s ~20 existing call sites across the
1767 /// workspace must remain unaffected by the addition of `spawn_oneshot_classified` —
1768 /// `spawn_oneshot` delegates with an always-true classifier (`|_| true`), so a plain
1769 /// (non-`Result`) factory value must still always be classified `Normal`/logged as a
1770 /// plain completion, never as an inner failure.
1771 #[tokio::test]
1772 #[tracing_test::traced_test]
1773 async fn test_oneshot_backward_compat_never_logs_inner_failure() {
1774 let (sup, _cancel) = make_supervisor();
1775
1776 let handle: BlockingHandle<u32> =
1777 sup.spawn_oneshot(Arc::from("plain-oneshot"), || async { 42_u32 });
1778 assert_eq!(handle.join().await.unwrap(), 42);
1779
1780 tokio::time::sleep(Duration::from_millis(50)).await;
1781
1782 assert!(
1783 !logs_contain("completed with an inner failure"),
1784 "spawn_oneshot's default always-true classifier must never report an inner failure"
1785 );
1786 }
1787
1788 /// Regression test for #6510: a `spawn_classified` task registered with
1789 /// `RestartPolicy::Restart` must classify an inner failure as `CompletionKind::Failed`
1790 /// and — critically — retain the registry entry as `TaskStatus::Failed` so it stays
1791 /// durably visible via [`TaskSupervisor::snapshot`] (the TUI's only data source),
1792 /// instead of being removed in the same lock acquisition that sets the status. Before
1793 /// this fix, `spawn`/`TaskDescriptor` only supported `Fut::Output = ()`, so a
1794 /// `Restart`-policy task could never produce anything but
1795 /// `CompletionKind::Normal | Panicked | Cancelled`; even once `Failed` became reachable,
1796 /// an earlier revision of this fix set the status and immediately removed the entry in
1797 /// the same critical section, leaving a zero-width observation window (#6510's own
1798 /// reproduction still showed the task vanish rather than surface as `Failed`). The
1799 /// primary assertion below is on `snapshot()`, not the log line, because a `snapshot()`
1800 /// poll is what would actually fail if the status-branch fix were reverted back to
1801 /// unconditional `TaskStatus::Completed`.
1802 #[tokio::test]
1803 #[tracing_test::traced_test]
1804 async fn spawn_classified_restart_reports_failed_for_inner_err() {
1805 let (sup, _cancel) = make_supervisor();
1806
1807 let _handle = sup.spawn_classified(
1808 TaskDescriptor {
1809 name: "classified-restart-fail",
1810 restart: RestartPolicy::Restart {
1811 max: 0,
1812 base_delay: Duration::from_millis(10),
1813 },
1814 factory: || async { Result::<(), &'static str>::Err("boom") },
1815 },
1816 Result::is_ok,
1817 );
1818
1819 // Give the reap driver a beat to process the completion.
1820 tokio::time::sleep(Duration::from_millis(50)).await;
1821
1822 let snaps = sup.snapshot();
1823 let snap = snaps
1824 .iter()
1825 .find(|s| s.name.as_ref() == "classified-restart-fail");
1826 assert!(
1827 matches!(snap.map(|s| &s.status), Some(TaskStatus::Failed { .. })),
1828 "an Err value from a Restart-policy spawn_classified task must surface as a \
1829 durably-retained TaskStatus::Failed entry in snapshot()/TUI, not vanish or \
1830 settle as Completed — got {snap:?}"
1831 );
1832 assert!(
1833 logs_contain("completed with an inner failure"),
1834 "the inner-failure warning log must still fire alongside the status change"
1835 );
1836 }
1837
1838 /// Counterpart to the above: an `Ok`-returning factory on a `Restart`-policy
1839 /// `spawn_classified` task must be classified `Completed` and removed from the
1840 /// registry exactly as before this fix — only the `Failed` path changes behavior.
1841 #[tokio::test]
1842 #[tracing_test::traced_test]
1843 async fn spawn_classified_restart_does_not_report_failed_for_inner_ok() {
1844 let (sup, _cancel) = make_supervisor();
1845
1846 let _handle = sup.spawn_classified(
1847 TaskDescriptor {
1848 name: "classified-restart-ok",
1849 restart: RestartPolicy::Restart {
1850 max: 0,
1851 base_delay: Duration::from_millis(10),
1852 },
1853 factory: || async { Result::<(), &'static str>::Ok(()) },
1854 },
1855 Result::is_ok,
1856 );
1857
1858 tokio::time::sleep(Duration::from_millis(50)).await;
1859
1860 assert!(
1861 sup.snapshot()
1862 .iter()
1863 .all(|s| s.name.as_ref() != "classified-restart-ok"),
1864 "an Ok completion must still be removed from the registry, unchanged from \
1865 pre-#6510 behavior — only Failed completions are now retained"
1866 );
1867 assert!(
1868 !logs_contain("completed with an inner failure"),
1869 "an Ok value must not be classified as CompletionKind::Failed"
1870 );
1871 }
1872
1873 /// A classified task registered with `RestartPolicy::Restart { max > 0, .. }` must still
1874 /// restart on panic (the classifier only affects status *reporting*, never the restart
1875 /// *decision*, which stays panic-only per `classify_completion`), and the classifier must
1876 /// keep working correctly on the post-restart attempt.
1877 #[tokio::test]
1878 async fn spawn_classified_restart_after_panic_then_classifies_next_attempt() {
1879 let (sup, _cancel) = make_supervisor();
1880
1881 let attempt = Arc::new(AtomicU32::new(0));
1882 let attempt2 = Arc::clone(&attempt);
1883
1884 let _handle = sup.spawn_classified(
1885 TaskDescriptor {
1886 name: "classified-restart-after-panic",
1887 restart: RestartPolicy::Restart {
1888 max: 3,
1889 base_delay: Duration::from_millis(10),
1890 },
1891 factory: move || {
1892 let n = attempt2.fetch_add(1, Ordering::SeqCst);
1893 async move {
1894 assert!(n != 0, "first attempt always panics");
1895 Result::<(), &'static str>::Err("boom on retry")
1896 }
1897 },
1898 },
1899 Result::is_ok,
1900 );
1901
1902 // Panic (attempt 0) triggers a restart; attempt 1 returns Err, which is terminal
1903 // (Restart only retries on panic) and must be classified Failed and retained.
1904 tokio::time::sleep(Duration::from_millis(300)).await;
1905
1906 assert_eq!(
1907 attempt.load(Ordering::SeqCst),
1908 2,
1909 "factory should run exactly twice: initial panic + one restart"
1910 );
1911 let snaps = sup.snapshot();
1912 let snap = snaps
1913 .iter()
1914 .find(|s| s.name.as_ref() == "classified-restart-after-panic");
1915 assert!(
1916 matches!(snap.map(|s| &s.status), Some(TaskStatus::Failed { .. })),
1917 "the post-restart Err attempt must classify and retain as Failed — got {snap:?}"
1918 );
1919 }
1920
1921 #[tokio::test]
1922 async fn test_restart_max_zero() {
1923 let (sup, _cancel) = make_supervisor();
1924
1925 let counter = Arc::new(AtomicU32::new(0));
1926 let counter2 = Arc::clone(&counter);
1927
1928 sup.spawn(TaskDescriptor {
1929 name: "zero-max",
1930 restart: RestartPolicy::Restart {
1931 max: 0,
1932 base_delay: Duration::from_millis(10),
1933 },
1934 factory: move || {
1935 let c = Arc::clone(&counter2);
1936 async move {
1937 c.fetch_add(1, Ordering::SeqCst);
1938 panic!("always panic");
1939 }
1940 },
1941 });
1942
1943 tokio::time::sleep(Duration::from_millis(200)).await;
1944
1945 assert_eq!(
1946 counter.load(Ordering::SeqCst),
1947 1,
1948 "max=0 should not restart"
1949 );
1950
1951 let snaps = sup.snapshot();
1952 let snap = snaps.iter().find(|s| s.name.as_ref() == "zero-max");
1953 assert!(snap.is_some(), "entry should remain as Failed");
1954 assert!(
1955 matches!(snap.unwrap().status, TaskStatus::Failed { .. }),
1956 "status should be Failed"
1957 );
1958 }
1959
1960 /// Stress test: spawn 50 tasks concurrently, all must complete and registry must be accurate.
1961 #[tokio::test]
1962 async fn test_concurrent_spawns() {
1963 // All task names must be 'static — pre-defined before any let statements.
1964 static NAMES: [&str; 50] = [
1965 "t00", "t01", "t02", "t03", "t04", "t05", "t06", "t07", "t08", "t09", "t10", "t11",
1966 "t12", "t13", "t14", "t15", "t16", "t17", "t18", "t19", "t20", "t21", "t22", "t23",
1967 "t24", "t25", "t26", "t27", "t28", "t29", "t30", "t31", "t32", "t33", "t34", "t35",
1968 "t36", "t37", "t38", "t39", "t40", "t41", "t42", "t43", "t44", "t45", "t46", "t47",
1969 "t48", "t49",
1970 ];
1971 let (sup, cancel) = make_supervisor();
1972
1973 let completed = Arc::new(AtomicU32::new(0));
1974 for name in &NAMES {
1975 let c = Arc::clone(&completed);
1976 sup.spawn(TaskDescriptor {
1977 name,
1978 restart: RestartPolicy::RunOnce,
1979 factory: move || {
1980 let c = Arc::clone(&c);
1981 async move {
1982 c.fetch_add(1, Ordering::SeqCst);
1983 }
1984 },
1985 });
1986 }
1987
1988 // Wait for all tasks to complete.
1989 tokio::time::timeout(Duration::from_secs(5), async {
1990 loop {
1991 if completed.load(Ordering::SeqCst) == 50 {
1992 break;
1993 }
1994 tokio::time::sleep(Duration::from_millis(10)).await;
1995 }
1996 })
1997 .await
1998 .expect("all 50 tasks should complete");
1999
2000 // Give reap driver time to process all completions.
2001 tokio::time::sleep(Duration::from_millis(100)).await;
2002 assert_eq!(sup.active_count(), 0, "all tasks must be reaped");
2003
2004 cancel.cancel();
2005 }
2006
2007 #[tokio::test]
2008 async fn test_shutdown_timeout_expiry() {
2009 let cancel = CancellationToken::new();
2010 let sup = TaskSupervisor::new(cancel.clone());
2011
2012 sup.spawn(TaskDescriptor {
2013 name: "stubborn",
2014 restart: RestartPolicy::RunOnce,
2015 factory: || async {
2016 tokio::time::sleep(Duration::from_mins(1)).await;
2017 },
2018 });
2019
2020 assert_eq!(sup.active_count(), 1);
2021
2022 tokio::time::timeout(
2023 Duration::from_secs(2),
2024 sup.shutdown_all(Duration::from_millis(50)),
2025 )
2026 .await
2027 .expect("shutdown_all should return even on timeout expiry");
2028
2029 assert!(
2030 cancel.is_cancelled(),
2031 "cancel token must be cancelled after shutdown"
2032 );
2033 }
2034
2035 #[tokio::test]
2036 async fn test_cancellation_token() {
2037 let cancel = CancellationToken::new();
2038 let sup = TaskSupervisor::new(cancel.clone());
2039
2040 assert!(!sup.cancellation_token().is_cancelled());
2041
2042 sup.shutdown_all(Duration::from_millis(100)).await;
2043
2044 assert!(
2045 sup.cancellation_token().is_cancelled(),
2046 "token must be cancelled after shutdown"
2047 );
2048 }
2049
2050 /// Regression test for #3161: after `shutdown_all`, all tasks must be reaped
2051 /// even when they complete *after* the cancel signal.
2052 ///
2053 /// The yield loop forces the reap driver to observe cancel and exit phase-1
2054 /// before the tasks send their completions — reliably reproducing the race.
2055 #[tokio::test]
2056 async fn test_shutdown_drains_post_cancel_completions() {
2057 let cancel = CancellationToken::new();
2058 let sup = TaskSupervisor::new(cancel.clone());
2059
2060 for name in [
2061 "loop-1", "loop-2", "loop-3", "loop-4", "loop-5", "loop-6", "loop-7",
2062 ] {
2063 let cancel_inner = cancel.clone();
2064 sup.spawn(TaskDescriptor {
2065 name,
2066 restart: RestartPolicy::RunOnce,
2067 factory: move || {
2068 let c = cancel_inner.clone();
2069 async move {
2070 c.cancelled().await;
2071 // Yield multiple times so the reap driver observes cancel first.
2072 for _ in 0..64 {
2073 tokio::task::yield_now().await;
2074 }
2075 }
2076 },
2077 });
2078 }
2079 assert_eq!(sup.active_count(), 7);
2080
2081 sup.shutdown_all(Duration::from_secs(2)).await;
2082
2083 assert_eq!(
2084 sup.active_count(),
2085 0,
2086 "all tasks must be reaped after shutdown (#3161)"
2087 );
2088 }
2089
2090 /// Regression test for #5926: `shutdown_all` must wait for a task that is
2091 /// still running when the caller-supplied `timeout` has not yet elapsed,
2092 /// rather than giving up on any independent, shorter deadline.
2093 ///
2094 /// `spawn_blocking` tasks don't observe the cancellation token — unlike
2095 /// `spawn`-based tasks, whose futures are dropped immediately by `do_spawn`'s
2096 /// `tokio::select!` against `cancel.cancelled()` — so a `spawn_blocking` task is
2097 /// the only way to have a task genuinely keep running past cancel and exercise
2098 /// the drain phase.
2099 #[tokio::test]
2100 async fn test_shutdown_all_reaps_blocking_task_finishing_after_cancel() {
2101 let (sup, _cancel) = make_supervisor();
2102
2103 let handle = sup.spawn_blocking(Arc::from("slow-blocking"), || {
2104 std::thread::sleep(Duration::from_millis(300));
2105 });
2106
2107 // Let the blocking task actually start before triggering shutdown.
2108 tokio::time::sleep(Duration::from_millis(20)).await;
2109
2110 let start = tokio::time::Instant::now();
2111 sup.shutdown_all(Duration::from_secs(2)).await;
2112 let elapsed = start.elapsed();
2113
2114 assert!(
2115 elapsed < Duration::from_secs(1),
2116 "shutdown_all must wake as soon as the blocking task actually finishes \
2117 (~300ms), not wait out the full 2s caller timeout; elapsed={elapsed:?}"
2118 );
2119
2120 // Successfully reaped tasks are removed from the registry entirely (see
2121 // `handle_completion`'s post-cancel short-circuit); only force-aborted tasks
2122 // remain with `TaskStatus::Aborted`. Absence here means the completion was
2123 // processed before shutdown_all's own timeout forced an abort.
2124 let snapshot = sup.snapshot();
2125 assert!(
2126 snapshot.iter().all(|t| t.name.as_ref() != "slow-blocking"),
2127 "task that completed cleanly after cancel must be reaped from the \
2128 registry, not left behind and force-aborted: {snapshot:?}"
2129 );
2130
2131 // `join` returns `Ok` only when the closure ran to completion and the
2132 // outer task wasn't aborted — the `BlockingHandle` equivalent of
2133 // `TaskStatus::Completed` vs `TaskStatus::Aborted`.
2134 handle
2135 .join()
2136 .await
2137 .expect("blocking task must complete, not be reported as aborted (#5926)");
2138 }
2139
2140 /// Regression test for #5926, critical topology (out-of-band cancel):
2141 /// production call sites (`src/runner.rs`, `src/serve/mod.rs`) always cancel
2142 /// the shared `CancellationToken` *before* calling `shutdown_all` — a
2143 /// separate shutdown bridge/signal handler owns the cancel, and `shutdown_all`
2144 /// is only invoked afterward as a waiter. A fix that only works when
2145 /// `shutdown_all` is the sole canceller would pass a naive test but stay
2146 /// broken in production. This test replicates the real topology explicitly:
2147 /// cancel first, let the reap driver start draining, *then* call
2148 /// `shutdown_all`, and confirm the caller's real timeout still governs the
2149 /// wait.
2150 #[tokio::test]
2151 async fn test_shutdown_all_reaps_task_after_out_of_band_cancel() {
2152 let cancel = CancellationToken::new();
2153 let sup = TaskSupervisor::new(cancel.clone());
2154
2155 let handle = sup.spawn_blocking(Arc::from("slow-blocking-oob"), || {
2156 std::thread::sleep(Duration::from_millis(300));
2157 });
2158 tokio::time::sleep(Duration::from_millis(20)).await;
2159
2160 // Out-of-band cancel — nothing to do with shutdown_all, mirrors a shutdown
2161 // bridge/signal handler cancelling the shared token directly.
2162 cancel.cancel();
2163 // Give the reap driver time to observe cancellation and enter Phase 2
2164 // before shutdown_all ever runs.
2165 tokio::time::sleep(Duration::from_millis(50)).await;
2166
2167 let start = tokio::time::Instant::now();
2168 sup.shutdown_all(Duration::from_secs(2)).await;
2169 let elapsed = start.elapsed();
2170
2171 assert!(
2172 elapsed < Duration::from_secs(1),
2173 "shutdown_all must wake once the blocking task actually finishes even \
2174 though the token was cancelled out-of-band before shutdown_all was \
2175 called; elapsed={elapsed:?}"
2176 );
2177
2178 handle.join().await.expect(
2179 "blocking task must complete, not be reported as aborted, even under \
2180 the out-of-band-cancel topology (#5926)",
2181 );
2182 }
2183
2184 /// Regression test for #5926 residual (critic finding S1): the reap driver's
2185 /// drain phase must never give up on a still-active, non-cooperative task on
2186 /// its own — only `shutdown_all`'s own `sleep(timeout)` may do that. An
2187 /// earlier version of the fix gave the drain phase its own short fallback
2188 /// deadline; if the gap between the out-of-band cancel and `shutdown_all`
2189 /// being invoked exceeded that fallback, the reap driver exited *before*
2190 /// `shutdown_all` ever ran, silently dropping the eventual completion and
2191 /// misreporting the task `Aborted`. This is realistic in production: e.g.
2192 /// `serve/mod.rs`'s `axum::serve(...).with_graceful_shutdown(...)` drains
2193 /// in-flight connections with no timeout of its own before `shutdown_all(30s)`
2194 /// is ever reached, and a long-running agent-turn request can hold that gap
2195 /// open well past any short fallback.
2196 ///
2197 /// Uses paused virtual time (`tokio::time::advance`) to jump the cancel-to-
2198 /// `shutdown_all` gap arbitrarily far forward without a real multi-second
2199 /// test. A `spawn_blocking` task runs on a real OS thread (unaffected by
2200 /// paused tokio time) and is held open via a channel until explicitly
2201 /// released, so its completion stays under exact manual control relative to
2202 /// the virtual-time advance and to when `shutdown_all` is actually called.
2203 #[tokio::test(start_paused = true)]
2204 async fn test_shutdown_all_reaps_task_after_long_pre_invocation_gap() {
2205 let cancel = CancellationToken::new();
2206 let sup = TaskSupervisor::new(cancel.clone());
2207
2208 let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
2209 let handle: BlockingHandle<()> = sup.spawn_blocking(Arc::from("held-task"), move || {
2210 let _ = release_rx.recv();
2211 });
2212
2213 // Out-of-band cancel — before shutdown_all is ever called.
2214 cancel.cancel();
2215 for _ in 0..8 {
2216 tokio::task::yield_now().await;
2217 }
2218
2219 // Advance virtual time by a large margin — deliberately larger than any
2220 // fallback window a naive fix might reintroduce — with `shutdown_all`
2221 // still not called and the task still held open. A drain phase with any
2222 // independent deadline of its own would have already exited by now.
2223 tokio::time::advance(Duration::from_mins(2)).await;
2224
2225 // *Now* the real caller shows up.
2226 let sup2 = sup.clone();
2227 let shutdown_jh =
2228 tokio::spawn(async move { sup2.shutdown_all(Duration::from_secs(30)).await });
2229 for _ in 0..8 {
2230 tokio::task::yield_now().await;
2231 }
2232
2233 // Let the real OS thread actually finish.
2234 let _ = release_tx.send(());
2235
2236 // Await directly (no wrapping virtual-time timeout): under `start_paused`,
2237 // an artificial timeout here would itself be subject to auto-advance and
2238 // cannot reliably distinguish outcomes. The real signal is the registry
2239 // state checked below, not how long this await took.
2240 shutdown_jh.await.expect("shutdown_all task must not panic");
2241
2242 // NOTE: `handle.join()` reflects `spawn_blocking`'s own internal oneshot
2243 // completion channel, which is fed directly by the real OS thread finishing
2244 // — entirely independent of the reap driver / registry path this test is
2245 // targeting. It succeeds either way and cannot distinguish the bug, so the
2246 // real assertion is the supervisor's registry state, not `handle.join()`.
2247 let snapshot = sup.snapshot();
2248 let entry = snapshot.iter().find(|t| t.name.as_ref() == "held-task");
2249 assert!(
2250 entry.is_none(),
2251 "a task finishing after shutdown_all was (belatedly) invoked must be \
2252 reaped from the registry (the post-cancel path removes completed \
2253 entries rather than marking them), not force-aborted, no matter how \
2254 long the cancel-to-shutdown_all gap was: {snapshot:?} (#5926 S1)"
2255 );
2256
2257 handle
2258 .join()
2259 .await
2260 .expect("blocking task must complete without panicking");
2261 }
2262
2263 #[tokio::test]
2264 async fn test_blocking_spawner_task_appears_in_snapshot() {
2265 // Verify that tasks spawned via BlockingSpawner appear in supervisor.snapshot().
2266 use crate::BlockingSpawner;
2267
2268 let cancel = CancellationToken::new();
2269 let sup = TaskSupervisor::new(cancel);
2270
2271 let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<()>();
2272 let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>();
2273
2274 let handle = sup.spawn_blocking_named(
2275 Arc::from("chunk_file"),
2276 Box::new(move || {
2277 // Signal that the task has started.
2278 let _ = ready_tx.send(());
2279 // Block until test signals release.
2280 let _ = release_rx.blocking_recv();
2281 }),
2282 );
2283
2284 // Wait until the blocking task has actually started.
2285 ready_rx.await.expect("task should start");
2286
2287 let snapshot = sup.snapshot();
2288 assert!(
2289 snapshot.iter().any(|t| t.name.as_ref() == "chunk_file"),
2290 "chunk_file task must appear in supervisor snapshot"
2291 );
2292
2293 // Release the blocking task and await completion.
2294 let _ = release_tx.send(());
2295 handle.await.expect("task should complete");
2296 }
2297
2298 /// Verify that `measure_blocking` emits wall-time and CPU-time histograms.
2299 ///
2300 /// `measure_blocking` calls `metrics::histogram!` on the current thread.
2301 /// We test it directly using a `DebuggingRecorder` installed as the thread-local
2302 /// recorder via `metrics::with_local_recorder`.
2303 #[test]
2304 fn test_measure_blocking_emits_metrics() {
2305 use metrics_util::debugging::DebuggingRecorder;
2306
2307 let recorder = DebuggingRecorder::new();
2308 let snapshotter = recorder.snapshotter();
2309
2310 // Call measure_blocking inside the local recorder scope so histogram! calls
2311 // are captured. The closure runs synchronously on this thread.
2312 metrics::with_local_recorder(&recorder, || {
2313 measure_blocking("test_task", || std::hint::black_box(42_u64));
2314 });
2315
2316 let snapshot = snapshotter.snapshot();
2317 let metric_names: Vec<String> = snapshot
2318 .into_vec()
2319 .into_iter()
2320 .map(|(k, _, _, _)| k.key().name().to_owned())
2321 .collect();
2322
2323 assert!(
2324 metric_names.iter().any(|n| n == "zeph.task.wall_time_ms"),
2325 "expected zeph.task.wall_time_ms histogram; got: {metric_names:?}"
2326 );
2327 assert!(
2328 metric_names.iter().any(|n| n == "zeph.task.cpu_time_ms"),
2329 "expected zeph.task.cpu_time_ms histogram; got: {metric_names:?}"
2330 );
2331 }
2332
2333 /// Verify that `spawn_blocking` semaphore limits concurrent OS-thread tasks to 8.
2334 ///
2335 /// Spawns 16 tasks. Each holds a barrier until 8 are waiting; then releases in order.
2336 /// If more than 8 run concurrently the test would either deadlock (waiting for 9+ to reach
2337 /// the barrier) or the counter would exceed 8 — both are caught.
2338 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
2339 async fn test_spawn_blocking_semaphore_cap() {
2340 let (sup, _cancel) = make_supervisor();
2341 let concurrent = Arc::new(AtomicU32::new(0));
2342 let max_concurrent = Arc::new(AtomicU32::new(0));
2343 let barrier = Arc::new(std::sync::Barrier::new(1)); // just a sync point
2344
2345 let mut handles = Vec::new();
2346 for i in 0u32..16 {
2347 let c = Arc::clone(&concurrent);
2348 let m = Arc::clone(&max_concurrent);
2349 let name: Arc<str> = Arc::from(format!("blocking-{i}").as_str());
2350 let h = sup.spawn_blocking(name, move || {
2351 let prev = c.fetch_add(1, Ordering::SeqCst);
2352 // Update observed maximum.
2353 let mut cur_max = m.load(Ordering::SeqCst);
2354 while prev + 1 > cur_max {
2355 match m.compare_exchange(cur_max, prev + 1, Ordering::SeqCst, Ordering::SeqCst)
2356 {
2357 Ok(_) => break,
2358 Err(x) => cur_max = x,
2359 }
2360 }
2361 // Simulate work.
2362 std::thread::sleep(std::time::Duration::from_millis(20));
2363 c.fetch_sub(1, Ordering::SeqCst);
2364 });
2365 handles.push(h);
2366 }
2367
2368 for h in handles {
2369 h.join().await.expect("blocking task should succeed");
2370 }
2371 drop(barrier);
2372
2373 let observed = max_concurrent.load(Ordering::SeqCst);
2374 assert!(
2375 observed <= 8,
2376 "observed {observed} concurrent blocking tasks; expected ≤ 8 (semaphore cap)"
2377 );
2378 }
2379}