Skip to main content

forge_supervise/
lib.rs

1//! Supervision for forge's long-lived background tasks.
2//!
3//! One registry owns every member: it starts the member's future, observes
4//! its completion through a [`JoinSet`], restarts a death with bounded
5//! backoff, refuses to resurrect a deliberate stop, and answers a snapshot
6//! of what is running. A member's own loop is untouched; only its spawn and
7//! its idempotency live here.
8
9use std::collections::HashMap;
10use std::future::Future;
11use std::pin::Pin;
12use std::sync::{Arc, Mutex, PoisonError, Weak};
13use std::time::{Duration, SystemTime};
14
15use tokio::sync::mpsc;
16use tokio::task::{AbortHandle, Id, JoinError, JoinSet};
17use tokio_util::sync::CancellationToken;
18
19/// The first wait after a death.
20pub const DEFAULT_START: Duration = Duration::from_secs(1);
21/// The longest wait between restarts.
22pub const DEFAULT_CAP: Duration = Duration::from_secs(60);
23/// How many consecutive failures a member is restarted through.
24pub const DEFAULT_GIVE_UP_AFTER: u32 = 10;
25/// How long a run must last before its death starts the count over.
26pub const DEFAULT_RESET_AFTER: Duration = Duration::from_secs(60);
27
28/// How a member's death is handled.
29#[derive(Clone, Copy, Debug, PartialEq, Eq)]
30pub enum Policy {
31    /// Report a death; never restart.
32    Never,
33    /// Restart with backoff, give up after a bounded number of attempts.
34    Backoff { start: Duration, cap: Duration, give_up_after: u32, reset_after: Duration },
35}
36
37impl Policy {
38    /// The crate's defaults: 1 s doubling to 60 s, ten failures, a 60 s
39    /// stable run resetting the count.
40    pub fn backoff() -> Self {
41        Self::Backoff {
42            start: DEFAULT_START,
43            cap: DEFAULT_CAP,
44            give_up_after: DEFAULT_GIVE_UP_AFTER,
45            reset_after: DEFAULT_RESET_AFTER,
46        }
47    }
48}
49
50/// What a member is doing, as a snapshot reports it.
51#[derive(Clone, Debug, PartialEq, Eq)]
52pub enum MemberState {
53    /// The member's future is running, or is waiting out a backoff before
54    /// its next run.
55    Running { since: SystemTime },
56    /// The member died and its restart budget is spent.
57    GivenUp { after: u32 },
58    /// The member was stopped deliberately, or a report-only member returned
59    /// on its own.
60    Stopped,
61}
62
63/// One member as a snapshot reports it.
64#[derive(Clone, Debug)]
65pub struct Member {
66    pub name: String,
67    pub policy: Policy,
68    pub state: MemberState,
69    pub restarts: u32,
70    pub last_error: Option<String>,
71}
72
73/// The process's supervisor. Cheap to clone; every clone reaches the same
74/// registry.
75#[derive(Clone)]
76pub struct Supervisor(Arc<Inner>);
77
78impl Supervisor {
79    /// Build the registry and start its driver on the current runtime.
80    ///
81    /// A caller with no runtime gets a registry whose driver starts on the
82    /// first [`Self::spawn`] inside one, so construction never needs a
83    /// runtime of its own.
84    pub fn new() -> Self {
85        let (tx, rx) = mpsc::unbounded_channel();
86        let inner =
87            Arc::new(Inner { tx, rx: Mutex::new(Some(rx)), state: Mutex::new(State::default()) });
88        inner.ensure_driver();
89        Self(inner)
90    }
91
92    /// Register `name` and start its first run.
93    ///
94    /// Returns `true` when this call started the member and `false` when it
95    /// was already running, so a caller that must know whether its factory
96    /// was adopted can read the answer. A second call while the member runs
97    /// is a no-op, which is what replaces each member's own idempotency flag.
98    /// A call after a stop or a give-up starts a new run and resets the
99    /// counter.
100    pub fn spawn<F, Fut>(&self, name: &str, policy: Policy, factory: F) -> bool
101    where
102        F: Fn() -> Fut + Send + Sync + 'static,
103        Fut: Future<Output = ()> + Send + 'static,
104    {
105        self.0.ensure_driver();
106        let factory: Factory = Arc::new(move || Box::pin(factory()));
107        let mut state = self.0.state.lock().unwrap_or_else(PoisonError::into_inner);
108        if state
109            .members
110            .get(name)
111            .is_some_and(|member| matches!(&member.state, MemberState::Running { .. }))
112        {
113            return false;
114        }
115        let generation = state.generations.get(name).copied().unwrap_or(0) + 1;
116        let token = CancellationToken::new();
117        state.generations.insert(name.to_owned(), generation);
118        state.tokens.insert(name.to_owned(), token.clone());
119        state.factories.insert(name.to_owned(), factory);
120        state.members.insert(
121            name.to_owned(),
122            Member {
123                name: name.to_owned(),
124                policy,
125                state: MemberState::Running { since: SystemTime::now() },
126                restarts: 0,
127                last_error: None,
128            },
129        );
130        drop(state);
131        let _ = self.0.tx.send(Command::Start { name: name.to_owned(), generation, token });
132        true
133    }
134
135    /// Stop `name` deliberately: cancel its run, abort it, and never
136    /// resurrect it.
137    ///
138    /// The stop names the generation it took down, so a run replaced by a
139    /// fresh `spawn` in the same window is left alone.
140    pub fn stop(&self, name: &str) {
141        let mut state = self.0.state.lock().unwrap_or_else(PoisonError::into_inner);
142        let Some(member) = state.members.get_mut(name) else { return };
143        member.state = MemberState::Stopped;
144        if let Some(token) = state.tokens.get(name) {
145            token.cancel();
146        }
147        let generation = state.generations.get(name).copied().unwrap_or(0);
148        drop(state);
149        let _ = self.0.tx.send(Command::Stop { name: name.to_owned(), generation });
150        tracing::debug!(
151            target: "forge_supervise",
152            event_name = "subsystem_stopped",
153            subsystem = %name,
154            "the subsystem was stopped",
155        );
156    }
157
158    /// Remove `name` from the registry, abort its run, and forget its
159    /// factory. A call after a stop is what keeps a per-host member name
160    /// from accumulating dead rows.
161    pub fn forget(&self, name: &str) {
162        let aborts: Vec<AbortHandle> = {
163            let mut state = self.0.state.lock().unwrap_or_else(PoisonError::into_inner);
164            if let Some(token) = state.tokens.remove(name) {
165                token.cancel();
166            }
167            state.members.remove(name);
168            state.factories.remove(name);
169            // **The generation outlives the member.** Removing it would let
170            // the next spawn reuse a number an old run's completion still
171            // carries, and that completion's cleanup would take the new
172            // run's abort handle.
173            let keys: Vec<(String, u64)> = state
174                .aborts
175                .keys()
176                .filter(|(member, _)| member.as_str() == name)
177                .cloned()
178                .collect();
179            keys.into_iter().filter_map(|key| state.aborts.remove(&key)).collect()
180        };
181        for abort in aborts {
182            abort.abort();
183        }
184    }
185
186    /// Every member, ordered by name.
187    pub fn snapshot(&self) -> Vec<Member> {
188        let state = self.0.state.lock().unwrap_or_else(PoisonError::into_inner);
189        let mut members: Vec<Member> = state.members.values().cloned().collect();
190        members.sort_by(|a, b| a.name.cmp(&b.name));
191        members
192    }
193}
194
195impl Default for Supervisor {
196    fn default() -> Self {
197        Self::new()
198    }
199}
200
201/// A member's future factory: called once per run, so a restart builds a
202/// fresh future from the same captured state. Shared rather than boxed so a
203/// caller can clone it out from under the registry lock before invoking it.
204type Factory = Arc<dyn Fn() -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
205
206enum Command {
207    Start { name: String, generation: u64, token: CancellationToken },
208    Restart { name: String, generation: u64, token: CancellationToken, reason: String },
209    Stop { name: String, generation: u64 },
210}
211
212struct Run {
213    name: String,
214    generation: u64,
215    token: CancellationToken,
216    /// The tokio clock, so a paused test's `advance` moves it with the
217    /// member's own sleeps.
218    started_at: tokio::time::Instant,
219}
220
221struct Inner {
222    tx: mpsc::UnboundedSender<Command>,
223    rx: Mutex<Option<mpsc::UnboundedReceiver<Command>>>,
224    state: Mutex<State>,
225}
226
227#[derive(Default)]
228struct State {
229    members: HashMap<String, Member>,
230    factories: HashMap<String, Factory>,
231    tokens: HashMap<String, CancellationToken>,
232    generations: HashMap<String, u64>,
233    /// **One handle per run, not one slot per name.** A stop then a spawn in
234    /// the same window must abort the run it stopped, not the new one.
235    aborts: HashMap<(String, u64), AbortHandle>,
236}
237
238impl Inner {
239    /// Start the driver on the current runtime, once. A caller with no
240    /// runtime leaves the receiver for the next call.
241    fn ensure_driver(self: &Arc<Self>) {
242        let mut slot = self.rx.lock().unwrap_or_else(PoisonError::into_inner);
243        let Some(rx) = slot.take() else { return };
244        match tokio::runtime::Handle::try_current() {
245            Ok(handle) => {
246                handle.spawn(drive(Arc::downgrade(self), rx));
247            }
248            Err(_) => {
249                *slot = Some(rx);
250            }
251        }
252    }
253}
254
255/// The registry's one task: it owns the [`JoinSet`], so a completion is
256/// observed with no per-member handle.
257async fn drive(weak: Weak<Inner>, mut rx: mpsc::UnboundedReceiver<Command>) {
258    let mut set = JoinSet::new();
259    let mut runs: HashMap<Id, Run> = HashMap::new();
260    loop {
261        tokio::select! {
262            command = rx.recv() => {
263                let Some(command) = command else { break };
264                let Some(inner) = weak.upgrade() else { break };
265                match command {
266                    Command::Start { name, generation, token } => {
267                        start_run(&inner, &mut set, &mut runs, &name, generation, token, None);
268                    }
269                    Command::Restart { name, generation, token, reason } => {
270                        start_run(
271                            &inner,
272                            &mut set,
273                            &mut runs,
274                            &name,
275                            generation,
276                            token,
277                            Some(&reason),
278                        );
279                    }
280                    Command::Stop { name, generation } => {
281                        abort_run(&inner, &name, generation);
282                    }
283                }
284            }
285            // **An empty set is not a ready branch.** `join_next_with_id`
286            // answers `None` at once when nothing is running, and polling it
287            // unguarded spins the driver at full CPU between runs.
288            joined = set.join_next_with_id(), if !set.is_empty() => {
289                let Some(joined) = joined else { continue };
290                let Some(inner) = weak.upgrade() else { break };
291                match joined {
292                    Ok((id, ())) => on_exit(&inner, &mut runs, id, None),
293                    Err(error) => {
294                        let id = error.id();
295                        on_exit(&inner, &mut runs, id, Some(error));
296                    }
297                }
298            }
299        }
300    }
301}
302
303/// Spawn one run of `name`, unless a newer run has replaced it. `restart`
304/// carries the death's reason when this is a restart.
305fn start_run(
306    inner: &Arc<Inner>,
307    set: &mut JoinSet<()>,
308    runs: &mut HashMap<Id, Run>,
309    name: &str,
310    generation: u64,
311    token: CancellationToken,
312    restart: Option<&str>,
313) {
314    // **A cancelled run is never started.** A stale queued `Start` or a
315    // restart timer that outlived its stop carries a cancelled token, and
316    // the generation alone can match again after a forget.
317    if token.is_cancelled() {
318        return;
319    }
320    let (factory, restarts) = {
321        let mut state = inner.state.lock().unwrap_or_else(PoisonError::into_inner);
322        if state.generations.get(name).copied() != Some(generation) {
323            return;
324        }
325        if restart.is_some()
326            && let Some(member) = state.members.get_mut(name)
327        {
328            member.state = MemberState::Running { since: SystemTime::now() };
329        }
330        let factory = state.factories.get(name).cloned();
331        let restarts = state.members.get(name).map_or(0, |member| member.restarts);
332        (factory, restarts)
333    };
334    // **The factory runs with no registry lock held.** A factory that
335    // re-enters the supervisor would deadlock every operation, and one that
336    // panicked would kill the driver and every member with it.
337    let Some(factory) = factory else { return };
338    let handle = set.spawn(factory());
339    let id = handle.id();
340    runs.insert(
341        id,
342        Run { name: name.to_owned(), generation, token, started_at: tokio::time::Instant::now() },
343    );
344    let mut state = inner.state.lock().unwrap_or_else(PoisonError::into_inner);
345    state.aborts.insert((name.to_owned(), generation), handle);
346    drop(state);
347    if let Some(reason) = restart {
348        tracing::warn!(
349            target: "forge_supervise",
350            event_name = "subsystem_restarted",
351            subsystem = %name,
352            reason = %reason,
353            restarts,
354            "the subsystem was restarted",
355        );
356    } else {
357        tracing::debug!(
358            target: "forge_supervise",
359            event_name = "subsystem_started",
360            subsystem = %name,
361            "the subsystem started",
362        );
363    }
364}
365
366/// Abort the run of `name` at `generation`, if that run is still the one
367/// registered. A newer run's handle is keyed by its own generation, so a
368/// stop that raced a fresh spawn leaves the new run alone.
369fn abort_run(inner: &Arc<Inner>, name: &str, generation: u64) {
370    let abort = {
371        let state = inner.state.lock().unwrap_or_else(PoisonError::into_inner);
372        state.aborts.get(&(name.to_owned(), generation)).cloned()
373    };
374    if let Some(abort) = abort {
375        abort.abort();
376    }
377}
378
379/// One run ended. A stop is recorded and left alone; a death is recorded and
380/// restarted per policy.
381fn on_exit(inner: &Arc<Inner>, runs: &mut HashMap<Id, Run>, id: Id, error: Option<JoinError>) {
382    let Some(run) = runs.remove(&id) else { return };
383    let ran_for = run.started_at.elapsed();
384    let stopped = run.token.is_cancelled();
385    let clean = error.is_none();
386    let reason = error_reason(error);
387    let mut state = inner.state.lock().unwrap_or_else(PoisonError::into_inner);
388    state.aborts.remove(&(run.name.clone(), run.generation));
389    if stopped {
390        return;
391    }
392    if state.generations.get(&run.name).copied() != Some(run.generation) {
393        return;
394    }
395    let Some(member) = state.members.get_mut(&run.name) else { return };
396    match member.policy {
397        Policy::Never => {
398            // **A report-only member that returns is not a failure.** The
399            // normal end of a per-host reader is a clean return; only a
400            // panic claims forge's health. Rule 20 applies, and a clean
401            // return leaves no error for the snapshot to draw as one.
402            if clean {
403                member.state = MemberState::Stopped;
404                member.last_error = None;
405                tracing::debug!(
406                    target: "forge_supervise",
407                    event_name = "subsystem_exited",
408                    subsystem = %run.name,
409                    "the subsystem exited",
410                );
411            } else {
412                member.state = MemberState::GivenUp { after: 0 };
413                member.last_error = Some(reason.clone());
414                tracing::warn!(
415                    target: "forge_supervise",
416                    event_name = "subsystem_exited",
417                    subsystem = %run.name,
418                    reason = %reason,
419                    "the subsystem exited and is not restarted",
420                );
421            }
422        }
423        Policy::Backoff { start, cap, give_up_after, reset_after } => {
424            member.last_error = Some(reason.clone());
425            if ran_for >= reset_after {
426                member.restarts = 0;
427            }
428            if member.restarts + 1 >= give_up_after {
429                member.state = MemberState::GivenUp { after: member.restarts };
430                tracing::error!(
431                    target: "forge_supervise",
432                    event_name = "subsystem_gave_up",
433                    subsystem = %run.name,
434                    restarts = member.restarts,
435                    reason = %reason,
436                    "the subsystem died again after its restart budget; it is left down",
437                );
438            } else {
439                member.restarts += 1;
440                let delay = backoff(start, cap, member.restarts);
441                tracing::warn!(
442                    target: "forge_supervise",
443                    event_name = "subsystem_exited",
444                    subsystem = %run.name,
445                    reason = %reason,
446                    restart_in_ms = delay.as_millis(),
447                    "the subsystem died; it will be restarted",
448                );
449                let weak = Arc::downgrade(inner);
450                let name = run.name.clone();
451                let generation = run.generation;
452                let token = run.token.clone();
453                tokio::spawn(async move {
454                    tokio::time::sleep(delay).await;
455                    if let Some(inner) = weak.upgrade() {
456                        let _ = inner.tx.send(Command::Restart { name, generation, token, reason });
457                    }
458                });
459            }
460        }
461    }
462}
463
464/// The wait before restart number `restarts`: `start` doubling, capped.
465fn backoff(start: Duration, cap: Duration, restarts: u32) -> Duration {
466    let factor = 1u32.checked_shl(restarts.saturating_sub(1)).unwrap_or(u32::MAX);
467    start.saturating_mul(factor).min(cap)
468}
469
470/// Why a run ended, in the words the snapshot and the log carry.
471fn error_reason(error: Option<JoinError>) -> String {
472    match error {
473        Some(error) if error.is_panic() => {
474            let payload = error.into_panic();
475            let text = payload
476                .downcast_ref::<&str>()
477                .map(|text| (*text).to_owned())
478                .or_else(|| payload.downcast_ref::<String>().cloned())
479                .unwrap_or_else(|| "a panic with no message".to_owned());
480            format!("panicked: {text}")
481        }
482        Some(error) if error.is_cancelled() => "cancelled".to_owned(),
483        Some(error) => format!("the task failed: {error}"),
484        None => "the task returned".to_owned(),
485    }
486}
487
488#[cfg(test)]
489mod tests {
490    use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
491
492    use super::*;
493
494    /// A factory that counts its runs, so a test watches the restart without
495    /// a member's own loop.
496    fn counting(
497        runs: Arc<AtomicU32>,
498    ) -> impl Fn() -> std::future::Ready<()> + Send + Sync + 'static {
499        move || {
500            runs.fetch_add(1, Ordering::SeqCst);
501            std::future::ready(())
502        }
503    }
504
505    fn member(supervisor: &Supervisor, name: &str) -> Member {
506        supervisor
507            .snapshot()
508            .into_iter()
509            .find(|member| member.name == name)
510            .expect("the member is registered")
511    }
512
513    /// **An immediate return restarts per policy.** Catches a supervisor
514    /// that observes the completion and never calls the factory again.
515    #[tokio::test(start_paused = true)]
516    async fn an_immediate_return_restarts_per_policy() {
517        let supervisor = Supervisor::new();
518        let runs = Arc::new(AtomicU32::new(0));
519        assert!(supervisor.spawn("probe", Policy::backoff(), counting(Arc::clone(&runs))));
520
521        tokio::task::yield_now().await;
522        tokio::task::yield_now().await;
523        assert_eq!(runs.load(Ordering::SeqCst), 1, "the first run started");
524
525        tokio::time::advance(DEFAULT_START).await;
526        tokio::task::yield_now().await;
527        tokio::task::yield_now().await;
528        assert_eq!(runs.load(Ordering::SeqCst), 2, "the immediate return restarted once");
529    }
530
531    /// **The wait doubles from `start` and stops at `cap`.** Catches a
532    /// supervisor that reuses the first interval, or one that grows without
533    /// a ceiling.
534    #[test]
535    fn backoff_doubles_then_caps() {
536        let start = Duration::from_secs(1);
537        let cap = Duration::from_secs(60);
538        let waits: Vec<u64> =
539            (1..=8).map(|restarts| backoff(start, cap, restarts).as_secs()).collect();
540        assert_eq!(waits, vec![1, 2, 4, 8, 16, 32, 60, 60], "start doubles and stops at the cap");
541    }
542
543    /// **A second spawn while the member runs is a no-op.** Catches a
544    /// registry that starts a second run of the same name.
545    #[tokio::test(start_paused = true)]
546    async fn a_second_spawn_while_running_is_a_no_op() {
547        let supervisor = Supervisor::new();
548        let runs = Arc::new(AtomicU32::new(0));
549        assert!(
550            supervisor.spawn("probe", Policy::backoff(), counting(Arc::clone(&runs))),
551            "the first starts"
552        );
553        assert!(
554            !supervisor.spawn("probe", Policy::backoff(), counting(Arc::clone(&runs))),
555            "the second is a no-op",
556        );
557        tokio::task::yield_now().await;
558        tokio::task::yield_now().await;
559        assert_eq!(runs.load(Ordering::SeqCst), 1, "only one run started");
560        assert_eq!(supervisor.snapshot().len(), 1, "one member in the registry");
561    }
562
563    /// **A panicking task is restarted until the budget is spent.** Catches
564    /// a supervisor that treats a panic as a stop, or one that never gives
565    /// up.
566    #[tokio::test(start_paused = true)]
567    async fn a_panicking_task_gives_up_after_the_budget() {
568        let supervisor = Supervisor::new();
569        let policy = Policy::Backoff {
570            start: Duration::from_millis(1),
571            cap: Duration::from_millis(1),
572            give_up_after: 3,
573            reset_after: DEFAULT_RESET_AFTER,
574        };
575        let (tx, mut rx) = mpsc::unbounded_channel();
576        assert!(supervisor.spawn("boom", policy, move || {
577            let tx = tx.clone();
578            async move {
579                let _ = tx.send(());
580                panic!("the pump died");
581            }
582        }));
583
584        // **The last failure is not advanced past.** The budget is spent on
585        // the third death, so a supervisor that restarts it instead would
586        // still be running when the snapshot is read.
587        for run in 0..3 {
588            rx.recv().await.expect("a run started");
589            if run < 2 {
590                tokio::time::advance(Duration::from_millis(1)).await;
591            }
592        }
593        tokio::task::yield_now().await;
594
595        let member = member(&supervisor, "boom");
596        assert!(
597            matches!(member.state, MemberState::GivenUp { .. }),
598            "the budget is spent: {:?}",
599            member.state,
600        );
601        assert!(
602            member.last_error.as_deref().is_some_and(|error| error.contains("the pump died")),
603            "the panic payload is recorded: {:?}",
604            member.last_error,
605        );
606    }
607
608    /// **A spawn after a give-up starts a new run and resets the counter.**
609    /// Catches a registry that leaves a given-up member dead for the process.
610    #[tokio::test(start_paused = true)]
611    async fn a_spawn_after_give_up_starts_a_new_run() {
612        let supervisor = Supervisor::new();
613        let policy = Policy::Backoff {
614            start: Duration::from_millis(1),
615            cap: Duration::from_millis(1),
616            give_up_after: 1,
617            reset_after: DEFAULT_RESET_AFTER,
618        };
619        let runs = Arc::new(AtomicU32::new(0));
620        assert!(supervisor.spawn("probe", policy, counting(Arc::clone(&runs))));
621        tokio::task::yield_now().await;
622        tokio::task::yield_now().await;
623        assert!(
624            matches!(member(&supervisor, "probe").state, MemberState::GivenUp { .. }),
625            "the budget is spent",
626        );
627
628        // The new run stays up, so the counter it inherits is read before
629        // its own first death would move it.
630        let (tx, mut rx) = mpsc::unbounded_channel();
631        assert!(
632            supervisor.spawn("probe", Policy::backoff(), move || {
633                let tx = tx.clone();
634                async move {
635                    let _ = tx.send(());
636                    std::future::pending::<()>().await;
637                }
638            }),
639            "a give-up can be re-spawned",
640        );
641        rx.recv().await.expect("the new run started");
642        assert_eq!(member(&supervisor, "probe").restarts, 0, "the new run resets the counter");
643        assert_eq!(runs.load(Ordering::SeqCst), 1, "the first factory ran once");
644    }
645
646    /// **A stop then a spawn of the same name aborts the run the stop took
647    /// down.** The stop's command names the generation it stopped, so a
648    /// fresh run registered under the same name in the same window is left
649    /// alone and the old run does not live on as a leaked task.
650    #[tokio::test(start_paused = true)]
651    async fn a_stop_then_spawn_aborts_the_run_it_stopped() {
652        struct Dropped(Arc<AtomicBool>);
653        impl Drop for Dropped {
654            fn drop(&mut self) {
655                self.0.store(true, Ordering::SeqCst);
656            }
657        }
658
659        let supervisor = Supervisor::new();
660        let (started_tx, mut started_rx) = mpsc::unbounded_channel();
661        let first_freed = Arc::new(AtomicBool::new(false));
662        let freed = Arc::clone(&first_freed);
663        assert!(supervisor.spawn("probe", Policy::backoff(), move || {
664            let started_tx = started_tx.clone();
665            let freed = Arc::clone(&freed);
666            async move {
667                let _dropped = Dropped(freed);
668                let _ = started_tx.send(());
669                std::future::pending::<()>().await;
670            }
671        }));
672        started_rx.recv().await.expect("the first run started");
673
674        supervisor.stop("probe");
675        // The new run registers before the driver processes the stop's
676        // command, which is the window the old one-slot map lost the old
677        // run's handle in.
678        assert!(supervisor.spawn("probe", Policy::backoff(), || async {
679            std::future::pending::<()>().await;
680        }));
681        for _ in 0..10 {
682            tokio::task::yield_now().await;
683        }
684
685        assert!(
686            first_freed.load(Ordering::SeqCst),
687            "the stopped run was aborted even though a new run took its name",
688        );
689        assert!(
690            matches!(member(&supervisor, "probe").state, MemberState::Running { .. }),
691            "the new run is the one alive",
692        );
693    }
694
695    /// **A report-only member that returns is stopped, not given up.** Its
696    /// normal end is not a failure, so the snapshot must not draw it as one.
697    #[tokio::test(start_paused = true)]
698    async fn a_never_member_that_returns_is_stopped() {
699        let supervisor = Supervisor::new();
700        let (tx, mut rx) = mpsc::unbounded_channel();
701        assert!(supervisor.spawn("probe", Policy::Never, move || {
702            let tx = tx.clone();
703            async move {
704                let _ = tx.send(());
705            }
706        }));
707        rx.recv().await.expect("the run started");
708        tokio::task::yield_now().await;
709
710        let member = member(&supervisor, "probe");
711        assert_eq!(member.state, MemberState::Stopped, "a clean return is not a give-up");
712        assert_eq!(member.restarts, 0, "nothing was restarted");
713        assert!(
714            member.last_error.is_none(),
715            "a clean return records no error: {:?}",
716            member.last_error,
717        );
718    }
719
720    /// **A report-only member that panics is given up with its payload.**
721    /// Catches a supervisor that treats a report-only death as a clean stop.
722    #[tokio::test(start_paused = true)]
723    async fn a_never_member_that_panics_is_given_up() {
724        let supervisor = Supervisor::new();
725        let (tx, mut rx) = mpsc::unbounded_channel();
726        assert!(supervisor.spawn("boom", Policy::Never, move || {
727            let tx = tx.clone();
728            async move {
729                let _ = tx.send(());
730                panic!("the report-only member died");
731            }
732        }));
733        rx.recv().await.expect("the run started");
734        tokio::task::yield_now().await;
735
736        let member = member(&supervisor, "boom");
737        assert!(matches!(member.state, MemberState::GivenUp { .. }), "a panic is a death");
738        assert!(
739            member
740                .last_error
741                .as_deref()
742                .is_some_and(|error| error.contains("the report-only member died")),
743            "the panic payload is recorded: {:?}",
744            member.last_error,
745        );
746    }
747
748    /// **A stopped member is never resurrected.** The run completes after
749    /// the stop, so a stop that failed to cancel it would restart it and the
750    /// second run would show on the channel. A stop is also not a failed
751    /// attempt: its `restarts` and `last_error` stay untouched, which is what
752    /// makes the explicit stop check load-bearing.
753    #[tokio::test(start_paused = true)]
754    async fn a_stopped_member_is_not_restarted() {
755        let supervisor = Supervisor::new();
756        let (tx, mut rx) = mpsc::unbounded_channel();
757        assert!(supervisor.spawn("probe", Policy::backoff(), move || {
758            let tx = tx.clone();
759            async move {
760                let _ = tx.send(());
761                tokio::time::sleep(Duration::from_millis(10)).await;
762            }
763        }));
764        rx.recv().await.expect("the run started");
765
766        supervisor.stop("probe");
767        // The first advance lets the run finish; the second fires any restart
768        // timer the death scheduled, so a stop that failed to cancel the run
769        // shows its second run here.
770        tokio::time::advance(Duration::from_secs(120)).await;
771        tokio::task::yield_now().await;
772        tokio::time::advance(Duration::from_secs(120)).await;
773        tokio::task::yield_now().await;
774
775        assert!(rx.try_recv().is_err(), "a stopped member is never resurrected");
776        let member = member(&supervisor, "probe");
777        assert_eq!(member.state, MemberState::Stopped, "the stop is recorded");
778        assert_eq!(member.restarts, 0, "a stop is not a failed attempt");
779        assert!(member.last_error.is_none(), "a stop is not a death");
780    }
781
782    /// **A stop during a backoff is still a stop.** The timer armed by the
783    /// death must not resurrect the member the stop took down. Catches a
784    /// supervisor whose stop only aborts a live run.
785    #[tokio::test(start_paused = true)]
786    async fn a_stop_during_backoff_is_not_resurrected() {
787        let supervisor = Supervisor::new();
788        let (tx, mut rx) = mpsc::unbounded_channel();
789        assert!(supervisor.spawn("probe", Policy::backoff(), move || {
790            let tx = tx.clone();
791            async move {
792                let _ = tx.send(());
793            }
794        }));
795        // The first run returns immediately, so the member is in backoff.
796        rx.recv().await.expect("the first run started");
797        tokio::task::yield_now().await;
798
799        supervisor.stop("probe");
800        tokio::time::advance(Duration::from_secs(120)).await;
801        tokio::task::yield_now().await;
802
803        assert!(rx.try_recv().is_err(), "a stop during backoff is never resurrected");
804        assert_eq!(
805            member(&supervisor, "probe").state,
806            MemberState::Stopped,
807            "the stop is recorded"
808        );
809    }
810
811    /// **A run that outlives `reset_after` starts the count over.** Catches
812    /// a supervisor that accumulates failures across a stable run.
813    #[tokio::test(start_paused = true)]
814    async fn a_run_that_outlives_reset_after_resets_the_counter() {
815        let supervisor = Supervisor::new();
816        let policy = Policy::Backoff {
817            start: Duration::from_millis(1),
818            cap: Duration::from_millis(1),
819            give_up_after: 5,
820            reset_after: Duration::from_secs(10),
821        };
822        let (tx, mut rx) = mpsc::unbounded_channel();
823        let runs = Arc::new(AtomicU32::new(0));
824        let counter = Arc::clone(&runs);
825        assert!(supervisor.spawn("probe", policy, move || {
826            let tx = tx.clone();
827            let counter = Arc::clone(&counter);
828            async move {
829                let run = counter.fetch_add(1, Ordering::SeqCst);
830                let _ = tx.send(run);
831                match run {
832                    0 => {}
833                    1 => tokio::time::sleep(Duration::from_secs(11)).await,
834                    _ => std::future::pending::<()>().await,
835                }
836            }
837        }));
838
839        assert_eq!(rx.recv().await, Some(0), "the first run returns immediately");
840        tokio::time::advance(Duration::from_millis(1)).await;
841        assert_eq!(rx.recv().await, Some(1), "the second run starts");
842        tokio::time::advance(Duration::from_secs(11)).await;
843        tokio::time::advance(Duration::from_millis(1)).await;
844        assert_eq!(rx.recv().await, Some(2), "the third run starts");
845
846        assert_eq!(
847            member(&supervisor, "probe").restarts,
848            1,
849            "a run that outlived reset_after starts the count over",
850        );
851    }
852}