Skip to main content

subc_daemon/
supervise.rs

1use std::{
2    collections::{HashMap, VecDeque},
3    error::Error,
4    fmt, io,
5    path::PathBuf,
6    process::{ExitStatus, Stdio},
7    sync::{Arc, Mutex, OnceLock},
8    time::{Duration, SystemTime, UNIX_EPOCH},
9};
10
11use cortexkit_log::Retention;
12use serde_json::Value;
13use subc_control::{
14    ClientControlPush, LiveSpawn, ModuleProtocol, RouteCloseReason, SpawnCursor, SpawnEvent,
15    SpawnEventKind, SpawnSnapshot, SupervisorHealthStatus, TerminalDisposition, TerminalExitKind,
16};
17use subc_protocol::{
18    manifest::{SelfSignalKind, SignalAnchor},
19    session::{
20        HealthReport, HealthStatus, ModuleControlCommand, ModuleControlRequest,
21        MODULE_CONTROL_OP_HEALTH_CHECK,
22    },
23    Flags, FrameType, Priority, SUBC_LAUNCH_NONCE_ENV, SUBC_MODULE_ID_ENV,
24};
25use tokio::{
26    process::{Child, Command},
27    sync::{mpsc, oneshot, watch, Mutex as AsyncMutex},
28    task::JoinHandle,
29    time::{sleep, sleep_until, timeout, timeout_at, Instant},
30};
31use tracing::{debug, error, info, warn};
32
33use crate::{
34    child_roster::ChildRoster,
35    daemon_config::{
36        CAPTURE_KEEP_ENV, CAPTURE_MAX_AGE_DAYS_ENV, CAPTURE_MAX_FILE_MB_ENV, CK_LOG_ENV,
37    },
38    forwarding::{
39        CloseReason, ForwardingError, ForwardingTable, GoodbyeTarget, ModuleControlRpcOutcome,
40        ModuleDrainTarget, PendingModuleControlRpc,
41    },
42    provenance::{spawned_file_identity, ExecutableIdentityProbe, SpawnedFileIdentity},
43    registry::{ConnectionId, RegistryError},
44    stderr_tail::{
45        pump_stderr_to, pump_stdout_to, ChildOutputSink, StderrRing, StderrTailConfig,
46        StderrTailSnapshot,
47    },
48    terminal_ring::{TerminalHistorySnapshot, TerminalRecord, TerminalRing, TerminalRingConfig},
49    Frame, FrameSink, Registry,
50};
51
52#[path = "supervise_swap.rs"]
53mod swap;
54
55/// Command-line flag used by supervised modules to find subc.
56///
57/// subc launches module-mode children as `<module> --subc <connection-file-path>`.
58/// The path points at the TCP+key connection file; it is not an ambient signal and
59/// is never inherited by standalone children.
60pub const SUBC_ARG: &str = "--subc";
61
62const DEFAULT_MAX_RESTARTS: u32 = 3;
63const DEFAULT_BACKOFF: Duration = Duration::from_millis(100);
64const DEFAULT_MAX_BACKOFF: Duration = Duration::from_secs(30);
65/// The span `DEFAULT_MAX_RESTARTS` is counted over. Ten minutes is long enough
66/// to contain a real crash loop (which respawns in seconds) and short enough
67/// that unrelated crashes hours apart never accumulate into a permanent stop.
68const DEFAULT_RESTART_WINDOW: Duration = Duration::from_secs(600);
69/// How long a drain waits for already-dispatched requests to finalize before
70/// the child is torn down. Sized for TOOL-SCALE work (bash, inspect, builds),
71/// not RPC-scale: the original 2s value silently cut nearly every real tool
72/// call at the fence, making the wait-for-finalize design decorative for the
73/// workloads it existed for. Quiescence short-circuits, so an idle module
74/// restarts immediately regardless of this value; the budget is spent only
75/// when a genuine in-flight request is worth finishing. Per-module override:
76/// `drain_timeout_ms` in subc.jsonc; per-restart override: the operator's
77/// `supervisor.restart{drain_timeout_ms}` (0 = cut now, for wedge bounces
78/// where a stuck request will never settle).
79pub const DEFAULT_DRAIN_TIMEOUT: Duration = Duration::from_secs(30);
80const REGISTRY_RELEASE_TIMEOUT: Duration = Duration::from_secs(1);
81const REGISTRY_RELEASE_POLL: Duration = Duration::from_millis(10);
82const STDERR_PUMP_DRAIN_TIMEOUT: Duration = Duration::from_millis(250);
83/// Maximum number of supervised process spawn/exit facts retained per daemon incarnation.
84pub const SPAWN_EVENT_RING_CAPACITY: usize = 4096;
85const SPAWN_SUBSCRIBER_BUFFER: usize = SPAWN_EVENT_RING_CAPACITY + 1;
86
87struct SupervisedChild {
88    child: Child,
89    /// The name of this process's cgroup: the module id, or for a swap
90    /// candidate the alternate name (see `swap::cgroup_name`).
91    #[cfg(target_os = "linux")]
92    module_id: String,
93    #[cfg(target_os = "linux")]
94    cgroup_placement: Option<subc_cgroup::Placement>,
95    stdout_pump: Option<JoinHandle<()>>,
96    stderr_pump: Option<JoinHandle<()>>,
97    stderr_ring: Arc<Mutex<StderrRing>>,
98    spawned_at_ms: u64,
99    spawned_from: PathBuf,
100    spawned_file_identity: Option<SpawnedFileIdentity>,
101    process_start_time: Option<u64>,
102    process_identity: Option<ProcessIdentity>,
103    pid: u32,
104    /// This process's entry in the daemon's child roster, released when the
105    /// process is reaped or this handle is dropped.
106    roster_guard: Option<crate::child_roster::RosterGuard>,
107}
108
109impl SupervisedChild {
110    fn id(&self) -> Option<u32> {
111        Some(self.pid)
112    }
113
114    fn process_identity(&self) -> Option<ProcessIdentity> {
115        self.process_identity
116    }
117
118    async fn wait(&mut self) -> io::Result<ExitStatus> {
119        let result = self.child.wait().await;
120        if result.is_ok() {
121            // Reaped: the pid is free for reuse, so shutdown must stop
122            // seeing it as one of ours.
123            self.roster_guard = None;
124        }
125        #[cfg(target_os = "linux")]
126        if result.is_ok() {
127            if let Some(placement) = self.cgroup_placement.take() {
128                remove_module_cgroup(&placement, &self.module_id);
129            }
130        }
131        result
132    }
133
134    fn start_kill(&mut self) -> io::Result<()> {
135        self.child.start_kill()
136    }
137
138    async fn drain_stderr(&mut self, module_id: &str) {
139        if let Some(mut pump) = self.stdout_pump.take() {
140            match timeout(STDERR_PUMP_DRAIN_TIMEOUT, &mut pump).await {
141                Ok(Ok(())) => {}
142                Ok(Err(error)) => {
143                    warn!(module_id, error = %error, "stdout pump ended unexpectedly");
144                }
145                Err(_) => {
146                    pump.abort();
147                    warn!(
148                        module_id,
149                        waited = ?STDERR_PUMP_DRAIN_TIMEOUT,
150                        "stdout pump did not drain before restart; stopped it before the next process"
151                    );
152                }
153            }
154        }
155
156        let Some(mut pump) = self.stderr_pump.take() else {
157            return;
158        };
159        match timeout(STDERR_PUMP_DRAIN_TIMEOUT, &mut pump).await {
160            Ok(Ok(())) => {}
161            Ok(Err(err)) => {
162                self.stderr_ring
163                    .lock()
164                    .unwrap_or_else(|poisoned| poisoned.into_inner())
165                    .mark_incomplete(format!("stderr pump ended unexpectedly: {err}"));
166                warn!(module_id, error = %err, "stderr pump ended before clean EOF");
167            }
168            Err(_) => {
169                pump.abort();
170                self.stderr_ring
171                    .lock()
172                    .unwrap_or_else(|poisoned| poisoned.into_inner())
173                    .mark_incomplete(format!(
174                        "stderr pump did not reach EOF within {:?} before restart",
175                        STDERR_PUMP_DRAIN_TIMEOUT
176                    ));
177                warn!(
178                    module_id,
179                    waited = ?STDERR_PUMP_DRAIN_TIMEOUT,
180                    "stderr pump did not drain before restart; stopped it before marking the new process"
181                );
182            }
183        }
184    }
185}
186
187fn registration_release_events() -> &'static watch::Sender<u64> {
188    static EVENTS: OnceLock<watch::Sender<u64>> = OnceLock::new();
189    EVENTS.get_or_init(|| {
190        let (sender, _receiver) = watch::channel(0);
191        sender
192    })
193}
194
195pub(crate) fn notify_registration_release() {
196    let events = registration_release_events();
197    let next_generation = (*events.borrow()).wrapping_add(1);
198    events.send_replace(next_generation);
199}
200
201/// How to launch one singleton module process.
202#[derive(Debug, Clone, PartialEq, Eq)]
203pub struct ModuleSpec {
204    pub module_id: String,
205    pub program: PathBuf,
206    pub args: Vec<String>,
207    pub env: Vec<(String, String)>,
208    /// When true this is a reserved module: each spawn gets a fresh one-time launch
209    /// nonce that the child must echo in its HELLO, so only the daemon-spawned
210    /// process can register this module_id (a security-boundary module like the
211    /// credential vault must not be impersonable while it is down/restarting).
212    pub reserved: bool,
213    /// Module-id prefixes this supervised module owns for reserved HELLO checks.
214    /// Prefixes come from daemon config and must end in `:` before they reach the
215    /// supervisor; the owner module's current spawn nonce authorizes claims under
216    /// each prefix.
217    pub reserved_prefixes: Vec<String>,
218    /// The wire protocol this module speaks, as DECLARED in daemon config.
219    ///
220    /// [`ModuleProtocol::None`] changes four things and nothing else: health
221    /// probing is suppressed, teardown sends SIGTERM before waiting,
222    /// `route.open` is refused, and the spawn passes NO `--subc <path>` argument
223    /// and NO launch nonce. `SUBC_MODULE_ID` still goes into the environment,
224    /// because a process ignores an environment variable it does not read.
225    ///
226    /// The argument is the part that cannot be "harmless to a process that
227    /// ignores it": a stock binary exits on an unknown flag before it listens
228    /// (`nats-server`: "flag provided but not defined: -subc"), which is how the
229    /// first conformance run against this mode found it. The nonce is withheld
230    /// because a process that will never present it gains nothing from holding
231    /// it, and a secret in the environment of a process that does not need it is
232    /// a leak surface for no benefit.
233    pub protocol: ModuleProtocol,
234    /// Whether two processes of this module may run at once, which is what a
235    /// blue/green swap does for the length of its overlap. Declared in daemon
236    /// config because the daemon must be able to answer it while the module is
237    /// down, and so a module cannot talk itself into it after registering.
238    pub overlap: ModuleOverlap,
239}
240
241/// Whether a module tolerates a second process of itself running alongside.
242///
243/// Most modules are single-writer on their store (a WAL, a capture log, a
244/// resident index behind a writer barrier), and two processes on one store
245/// corrupt it. So a swap, which overlaps the old and new process by design,
246/// is refused unless the module's config opts in with `overlap: "safe"`.
247#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
248pub enum ModuleOverlap {
249    /// Never run two processes of this module at once. The default.
250    #[default]
251    Exclusive,
252    /// The module has said a second process of itself is harmless for the
253    /// length of a swap.
254    ///
255    /// Declare it only if a second instance can run for a few seconds without
256    /// touching ANY single-writer store: every database, WAL, index, projector
257    /// and scheduled job the module owns. A lease on part of that state is not
258    /// enough. broca's session lease guards WAL appends while its run index, its
259    /// store projector and its archive fold timer (which unlinks live WAL files)
260    /// stay single-writer, so broca is exclusive despite holding a lease. The
261    /// refusal only fires after this has been decided, so the decision is the
262    /// check.
263    Safe,
264}
265
266impl ModuleOverlap {
267    pub fn as_str(self) -> &'static str {
268        match self {
269            Self::Exclusive => "exclusive",
270            Self::Safe => "safe",
271        }
272    }
273}
274
275/// Environment variable telling a spawned module which case it was started
276/// for, before it sends HELLO. Only a swap candidate carries it, as
277/// [`SPAWN_ROLE_SWAP_CANDIDATE`]; every other spawn has it removed.
278///
279/// It chooses a warm-up budget, nothing else: a swap candidate can warm for
280/// longer because nobody waits on it, while a plain restart must flip ready
281/// quickly because callers see `module_warming` until it does. Absence means
282/// plain restart, the safe reading. The daemon trusts nothing about it; the
283/// candidate is proven by its launch nonce at HELLO.
284pub const SUBC_SPAWN_ROLE_ENV: &str = "SUBC_SPAWN_ROLE";
285/// The one value of [`SUBC_SPAWN_ROLE_ENV`] the daemon sets.
286pub const SPAWN_ROLE_SWAP_CANDIDATE: &str = "swap_candidate";
287/// How long a swap waits for its candidate to register and declare itself
288/// ready when the operator does not say. A module warming as a swap candidate
289/// may take up to 90 s (aft's ceiling, the largest in the fleet), so the
290/// daemon allows that plus time to start the process and send HELLO.
291pub const DEFAULT_SWAP_READY_TIMEOUT: Duration = Duration::from_secs(100);
292
293/// Bounded restart policy for crash exits.
294///
295/// `max_restarts` is the number of replacement processes allowed after the
296/// initial spawn WITHIN `window`. After that many crash restarts inside one
297/// window the module enters [`ModuleState::Failed`] and the supervisor stops
298/// the crash loop.
299///
300/// The budget is a RATE, not a lifetime total. It used to be a lifetime total,
301/// and that only survived because crashes were rare: a module that crashed
302/// three times across a week was disabled forever by crashes that had nothing
303/// to do with each other. That stopped being survivable once modules began
304/// exiting non-zero whenever the daemon's connection to them drops, because
305/// then every daemon-side connection drop spends a unit of the same budget and
306/// one flappy hour permanently stops a healthy module. Restarts older than
307/// `window` release their slot, so a module that crashed twice yesterday has a
308/// full budget today, while a genuine crash loop -- which is fast by
309/// definition -- still reaches the cap and stops.
310#[derive(Debug, Clone, Copy, PartialEq, Eq)]
311pub struct RestartPolicy {
312    pub max_restarts: u32,
313    /// Base delay before a crash replacement. The actual delay escalates with
314    /// the number of recent crash replacements and is capped by `max_backoff`.
315    pub backoff: Duration,
316    /// Maximum delay before a crash replacement.
317    pub max_backoff: Duration,
318    /// The span `max_restarts` is counted over. `Duration::ZERO` makes the
319    /// budget effectively infinite (nothing is ever in-window), which is why
320    /// daemon config refuses `window_secs: 0` rather than quietly accepting it.
321    pub window: Duration,
322}
323
324impl RestartPolicy {
325    /// A policy with the default crash window. Callers that care about the
326    /// window say so with [`Self::with_window`]; the ones that do not are
327    /// asking for the standard rate limit, not for no limit.
328    pub fn new(max_restarts: u32, backoff: Duration) -> Self {
329        Self {
330            max_restarts,
331            backoff,
332            max_backoff: DEFAULT_MAX_BACKOFF,
333            window: DEFAULT_RESTART_WINDOW,
334        }
335    }
336
337    pub fn with_max_backoff(mut self, max_backoff: Duration) -> Self {
338        self.max_backoff = max_backoff;
339        self
340    }
341
342    pub fn with_window(mut self, window: Duration) -> Self {
343        self.window = window;
344        self
345    }
346
347    /// Calculate the capped exponential delay for the next crash replacement.
348    /// `restart_in_window` is zero for the first replacement after an operator
349    /// action (restart, reload, re-enable) cleared the crash ring, or after all
350    /// older crash replacements have aged out of the window.
351    fn delay_for_restart(&self, restart_in_window: u32) -> Duration {
352        if self.backoff.is_zero() || self.max_backoff.is_zero() {
353            return Duration::ZERO;
354        }
355
356        let mut delay = self.backoff;
357        for _ in 0..restart_in_window {
358            if delay >= self.max_backoff {
359                return self.max_backoff;
360            }
361            delay = delay
362                .checked_mul(10)
363                .unwrap_or(self.max_backoff)
364                .min(self.max_backoff);
365        }
366        delay.min(self.max_backoff)
367    }
368
369    /// The one sentence that explains a budget-exhausted stop, used for both the
370    /// log line and the terminal record so the two cannot drift. It names the
371    /// window because `max_restarts=3` alone reads as a lifetime cap, which is
372    /// exactly what this budget is not.
373    fn budget_exhausted_detail(&self) -> String {
374        format!(
375            "crash budget exhausted: max_restarts={} within window_secs={}",
376            self.max_restarts,
377            self.window.as_secs()
378        )
379    }
380}
381
382impl Default for RestartPolicy {
383    fn default() -> Self {
384        Self {
385            max_restarts: DEFAULT_MAX_RESTARTS,
386            backoff: DEFAULT_BACKOFF,
387            max_backoff: DEFAULT_MAX_BACKOFF,
388            window: DEFAULT_RESTART_WINDOW,
389        }
390    }
391}
392
393#[derive(Debug, Clone, Copy, PartialEq, Eq)]
394struct CrashRestartSchedule {
395    restart_in_window: u32,
396    delay: Duration,
397}
398
399/// Whether the daemon itself will bring this module back after the exit being
400/// handled: it is enabled AND its in-window crash restarts are below the cap.
401///
402/// Takes `&mut` because reading the budget prunes it. Instants that fell out of
403/// the window are dropped here rather than by a timer, so the count is right
404/// the moment somebody asks and no bookkeeping runs for idle modules.
405fn daemon_will_restart(
406    state: &mut SupervisorSnapshot,
407    policy: &RestartPolicy,
408    now: Instant,
409) -> bool {
410    state.enabled && state.crash_restarts_in_window(policy.window, now) < policy.max_restarts
411}
412
413const DEFAULT_HEALTH_CADENCE: Duration = Duration::from_secs(30);
414const DEFAULT_HEALTH_DEADLINE: Duration = Duration::from_secs(5);
415const DEFAULT_HEALTH_FAILURE_THRESHOLD: u32 = 3;
416const MAX_HEALTH_METRICS_BYTES: usize = 16 * 1024;
417
418#[derive(Debug, Clone, Copy, PartialEq, Eq)]
419pub enum HealthAction {
420    Report,
421    Restart,
422    Alert,
423}
424
425impl fmt::Display for HealthAction {
426    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
427        f.write_str(match self {
428            Self::Report => "report",
429            Self::Restart => "restart",
430            Self::Alert => "alert",
431        })
432    }
433}
434
435#[derive(Debug, Clone, Copy, PartialEq, Eq)]
436pub struct HealthConfig {
437    pub cadence: Duration,
438    pub deadline: Duration,
439    pub failure_threshold: u32,
440    pub on_degraded: HealthAction,
441    pub on_failing: HealthAction,
442    pub critical: bool,
443}
444
445impl Default for HealthConfig {
446    fn default() -> Self {
447        Self {
448            cadence: DEFAULT_HEALTH_CADENCE,
449            deadline: DEFAULT_HEALTH_DEADLINE,
450            failure_threshold: DEFAULT_HEALTH_FAILURE_THRESHOLD,
451            on_degraded: HealthAction::Report,
452            on_failing: HealthAction::Report,
453            critical: false,
454        }
455    }
456}
457
458/// The supervisor's view of one module's health, relayed to clients over
459/// channel-0 and rendered by `ck health`.
460///
461/// THIS TYPE IS WHERE THE ABSENCE MEANINGS ARE CREATED, which is why they are
462/// stated here rather than only at the wire type a consumer reads. A reader can
463/// look up what `None` means; only a writer can silently change it, and the
464/// writer has no reason to go looking at a downstream contract before editing.
465///
466/// `last_probe_ms: None` MEANS NEVER PROBED, not probed-long-ago. It is cleared
467/// back to `None` on re-registration precisely so a respawned module does not
468/// carry its predecessor's timestamp — so an old value and an absent one call for
469/// opposite readings, and anything that defaulted this to a number would make a
470/// never-probed module indistinguishable from one probed at the epoch.
471///
472/// `detail` and `metrics` are `None` when the module published none on this
473/// probe, which does not mean it reported nothing wrong — it is also the shape
474/// when the probe never reached it. `last_probe_ms` is what separates those.
475#[derive(Debug, Clone, PartialEq)]
476pub struct ModuleHealthStatus {
477    pub status: SupervisorHealthStatus,
478    pub last_probe_ms: Option<u64>,
479    pub detail: Option<String>,
480    pub metrics: Option<Value>,
481    pub consecutive_failures: u32,
482    /// Number of replies received after a recurring health probe's deadline.
483    /// Unlike a timeout, every increment proves the module was alive.
484    pub late_answer_count: u64,
485    /// End-to-end latency of the newest late reply, measured from probe start.
486    pub last_late_answer_latency_ms: Option<u64>,
487    pub last_action: Option<String>,
488    /// Set together with `last_action`; the pair moves as one, and both being
489    /// absent means no escalation has ever been taken rather than that the last
490    /// one succeeded.
491    pub last_action_ms: Option<u64>,
492}
493
494impl Default for ModuleHealthStatus {
495    fn default() -> Self {
496        Self {
497            status: SupervisorHealthStatus::Unknown,
498            last_probe_ms: None,
499            detail: None,
500            metrics: None,
501            consecutive_failures: 0,
502            late_answer_count: 0,
503            last_late_answer_latency_ms: None,
504            last_action: None,
505            last_action_ms: None,
506        }
507    }
508}
509
510/// Typed lifecycle state for a supervised module.
511#[derive(Debug, Clone, Copy, PartialEq, Eq)]
512pub enum ModuleState {
513    Starting,
514    Running,
515    Unresponsive,
516    Restarting,
517    Draining,
518    Stopped,
519    Failed,
520    Disabled,
521}
522
523impl fmt::Display for ModuleState {
524    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
525        f.write_str(match self {
526            Self::Starting => "starting",
527            Self::Running => "running",
528            Self::Unresponsive => "unresponsive",
529            Self::Restarting => "restarting",
530            Self::Draining => "draining",
531            Self::Stopped => "stopped",
532            Self::Failed => "failed",
533            Self::Disabled => "disabled",
534        })
535    }
536}
537
538/// Supervisor classification of a child-process exit.
539#[derive(Debug, Clone, Copy, PartialEq, Eq)]
540pub enum ExitKind {
541    Clean,
542    Crash,
543    DeliberateSeverance,
544}
545
546impl From<ExitKind> for TerminalExitKind {
547    fn from(kind: ExitKind) -> Self {
548        match kind {
549            ExitKind::Clean => Self::Clean,
550            ExitKind::Crash => Self::Crash,
551            ExitKind::DeliberateSeverance => Self::DeliberateSeverance,
552        }
553    }
554}
555
556/// Exact process identity retained when a supervised module registers its
557/// connection. PID reuse makes a PID alone insufficient evidence of ownership.
558#[derive(Debug, Clone, Copy, PartialEq, Eq)]
559pub(crate) struct ProcessIdentity {
560    pub(crate) pid: u32,
561    pub(crate) start_time: u64,
562}
563
564/// Last observed child exit, if any.
565#[derive(Debug, Clone, PartialEq, Eq)]
566pub struct ExitReport {
567    pub kind: ExitKind,
568    pub code: Option<i32>,
569    pub signal: Option<i32>,
570    pub at_ms: u64,
571}
572
573/// Point-in-time module status answerable by subc without forwarding to the
574/// module process.
575#[derive(Debug, Clone, PartialEq)]
576pub struct ModuleStatus {
577    pub module_id: String,
578    pub state: ModuleState,
579    pub enabled: bool,
580    pub process_alive: bool,
581    pub registration_active: bool,
582    /// The module's declared wire protocol, carried beside `live` because it is
583    /// what makes `live` readable: the two fields answer one question together.
584    pub protocol: ModuleProtocol,
585    /// Whether the module is serving, under the strongest definition the daemon
586    /// can assert for its protocol.
587    ///
588    /// A subc module must also be REGISTERED: its process being alive says
589    /// nothing about whether it can take a request. A `protocol: "none"` module
590    /// never registers, so that term is dropped and this falls back to "enabled,
591    /// running, and the process the daemon launched is alive" -- which is all
592    /// the daemon observes about a process that speaks no subc wire. It stays a
593    /// `bool` on the wire for compatibility; renderers pair it with `protocol`
594    /// rather than printing it bare.
595    pub live: bool,
596    /// Crash restarts spent INSIDE `restart_window` as of this read. Older
597    /// restarts have already released their slot, so this count can go down
598    /// without anybody touching the module.
599    pub restart_count: u32,
600    /// Replacement processes spawned over this module's entire supervisor lifetime;
601    /// unlike `restart_count`, this value is never reset by an operator action
602    /// and never falls out of a window.
603    pub lifetime_restarts: u32,
604    pub spawn_generation: u64,
605    /// The budget `restart_count` is spent against. Carried alongside the count
606    /// because the count alone does not say how close the module is to being
607    /// disabled, and reporting one without the other is what makes an
608    /// about-to-be-retired module look ordinary.
609    pub max_restarts: u32,
610    /// The span `restart_count` is counted over. Carried with the pair above for
611    /// the same reason they are carried together: "2 of 3" means one thing for a
612    /// ten-minute window and something else entirely for a lifetime.
613    pub restart_window: Duration,
614    /// Effective drain and restart timing policy used by this running module.
615    /// These values are carried together with the restart budget so status
616    /// readers can compare configured intent with what the supervisor applied.
617    pub drain_timeout: Duration,
618    pub restart_backoff: Duration,
619    pub restart_max_backoff: Duration,
620    pub pid: Option<u32>,
621    pub spawned_at_ms: Option<u64>,
622    pub spawned_from: Option<PathBuf>,
623    pub process_start_time: Option<u64>,
624    pub last_exit: Option<ExitReport>,
625    pub health: ModuleHealthStatus,
626}
627
628#[derive(Debug, Clone, PartialEq)]
629struct SupervisorSnapshot {
630    state: ModuleState,
631    enabled: bool,
632    process_alive: bool,
633    /// When each crash restart was spent, oldest first. This IS the crash
634    /// budget: its in-window length is the count an operator sees and the count
635    /// the restart decision is made against, so there is no second counter that
636    /// can disagree with it. Bounded by `max_restarts`, and cleared by the same
637    /// operator actions that used to zero the old lifetime counter.
638    crash_restarts: VecDeque<Instant>,
639    lifetime_restarts: u32,
640    /// Successful child spawns in this daemon incarnation.
641    ///
642    /// `lifetime_restarts` was considered and rejected: it starts at zero
643    /// (line 640), successful initial/operator spawns in `set_running` do not
644    /// increment it (lines 5264-5274), and crash/deliberate retry bookkeeping
645    /// increments before a successful replacement exists (lines 604, 3846,
646    /// and 3921), so a failed spawn can consume it. This counter moves only
647    /// when a live PID is accepted below.
648    spawn_generation: u64,
649    pid: Option<u32>,
650    spawned_at_ms: Option<u64>,
651    spawned_from: Option<PathBuf>,
652    spawned_file_identity: Option<SpawnedFileIdentity>,
653    process_start_time: Option<u64>,
654    deliberate_severance: Option<ProcessIdentity>,
655    last_exit: Option<ExitReport>,
656    health: ModuleHealthStatus,
657    /// Whether the current process was started as a swap candidate and so
658    /// lives in the module's alternate cgroup. The next swap's candidate takes
659    /// the other one, so the two processes of a swap never share a cgroup. A
660    /// plain spawn always uses the primary cgroup.
661    in_alternate_slot: bool,
662}
663
664impl SupervisorSnapshot {
665    fn starting() -> Self {
666        Self::new(ModuleState::Starting, true)
667    }
668
669    fn disabled() -> Self {
670        Self::new(ModuleState::Disabled, false)
671    }
672
673    fn failed() -> Self {
674        Self::new(ModuleState::Failed, true)
675    }
676
677    /// Crash restarts still inside `window`, having dropped the ones that are
678    /// not. Pruning on read is what makes the budget a rate: an instant older
679    /// than the window stops holding a slot the moment anybody counts.
680    fn crash_restarts_in_window(&mut self, window: Duration, now: Instant) -> u32 {
681        while let Some(oldest) = self.crash_restarts.front() {
682            if now.duration_since(*oldest) > window {
683                self.crash_restarts.pop_front();
684            } else {
685                break;
686            }
687        }
688        u32::try_from(self.crash_restarts.len()).unwrap_or(u32::MAX)
689    }
690
691    /// Spend one unit of the crash budget and record the restart in the ledger.
692    ///
693    /// The ring is bounded by the cap because more than `max_restarts` in-window
694    /// instants can never be reached (the caller refuses the restart first), so
695    /// anything beyond that is an unbounded queue waiting to happen.
696    fn record_crash_restart(&mut self, policy: &RestartPolicy, now: Instant) {
697        self.crash_restarts.push_back(now);
698        while self.crash_restarts.len() > policy.max_restarts as usize {
699            self.crash_restarts.pop_front();
700        }
701        self.lifetime_restarts += 1;
702    }
703
704    /// Reserve one crash-restart slot and calculate the delay before respawning.
705    /// The count is captured before recording this restart, so the first retry
706    /// uses the base delay and each later in-window retry escalates once.
707    fn next_crash_restart(
708        &mut self,
709        policy: &RestartPolicy,
710        now: Instant,
711    ) -> Option<CrashRestartSchedule> {
712        let restart_in_window = self.crash_restarts_in_window(policy.window, now);
713        if restart_in_window >= policy.max_restarts {
714            return None;
715        }
716        self.record_crash_restart(policy, now);
717        Some(CrashRestartSchedule {
718            restart_in_window,
719            delay: policy.delay_for_restart(restart_in_window),
720        })
721    }
722
723    /// Give the module its full budget back, as an operator restart, reload, or
724    /// re-enable does. `lifetime_restarts` deliberately does not move: it is the
725    /// ledger of what actually happened, and an operator action does not unmake
726    /// the crashes.
727    fn clear_crash_restarts(&mut self) {
728        self.crash_restarts.clear();
729    }
730
731    fn new(state: ModuleState, enabled: bool) -> Self {
732        Self {
733            state,
734            enabled,
735            process_alive: false,
736            crash_restarts: VecDeque::new(),
737            lifetime_restarts: 0,
738            spawn_generation: 0,
739            pid: None,
740            spawned_at_ms: None,
741            spawned_from: None,
742            spawned_file_identity: None,
743            process_start_time: None,
744            deliberate_severance: None,
745            last_exit: None,
746            health: ModuleHealthStatus::default(),
747            in_alternate_slot: false,
748        }
749    }
750}
751
752type SharedSnapshot = Arc<Mutex<SupervisorSnapshot>>;
753
754type SpawnSubscriberKey = (ConnectionId, u64);
755
756#[derive(Debug)]
757struct SpawnSubscriber {
758    version: u8,
759    frames: mpsc::Sender<Frame>,
760}
761
762#[derive(Debug)]
763struct SpawnEventState {
764    daemon_incarnation: String,
765    seq: u64,
766    capacity: usize,
767    live: HashMap<String, LiveSpawn>,
768    generations: HashMap<String, u64>,
769    events: VecDeque<SpawnEvent>,
770    subscribers: HashMap<SpawnSubscriberKey, SpawnSubscriber>,
771}
772
773impl Default for SpawnEventState {
774    fn default() -> Self {
775        Self {
776            daemon_incarnation: "unconfigured".to_string(),
777            seq: 0,
778            capacity: SPAWN_EVENT_RING_CAPACITY,
779            live: HashMap::new(),
780            generations: HashMap::new(),
781            events: VecDeque::new(),
782            subscribers: HashMap::new(),
783        }
784    }
785}
786
787#[derive(Debug, Clone, Default)]
788struct SpawnEventFeed(Arc<Mutex<SpawnEventState>>);
789
790#[derive(Debug, Clone, PartialEq, Eq)]
791pub(crate) enum SpawnSubscribeRefusal {
792    ForeignIncarnation { current: String },
793    TooOld { oldest: SpawnCursor },
794    Frame(String),
795}
796
797impl SpawnEventFeed {
798    fn configure_incarnation(&self, daemon_incarnation: String) {
799        let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
800        state.daemon_incarnation = daemon_incarnation;
801        state.seq = 0;
802        state.live.clear();
803        state.generations.clear();
804        state.events.clear();
805        state.subscribers.clear();
806    }
807
808    fn cursor(state: &SpawnEventState) -> SpawnCursor {
809        SpawnCursor {
810            daemon_incarnation: state.daemon_incarnation.clone(),
811            seq: state.seq,
812        }
813    }
814
815    fn snapshot(&self) -> SpawnSnapshot {
816        let state = self.0.lock().unwrap_or_else(|p| p.into_inner());
817        let mut live = state.live.values().cloned().collect::<Vec<_>>();
818        live.sort_by(|left, right| left.module_id.cmp(&right.module_id));
819        SpawnSnapshot {
820            cursor: Self::cursor(&state),
821            ring_bound: state.capacity as u64,
822            live,
823        }
824    }
825
826    fn emit_spawned(&self, module_id: &str, pid: u32, spawned_at_ms: u64) -> u64 {
827        let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
828        let generation = state
829            .generations
830            .get(module_id)
831            .copied()
832            .unwrap_or(0)
833            .checked_add(1)
834            .expect("spawn generation exhausted");
835        state.generations.insert(module_id.to_string(), generation);
836        let live = LiveSpawn {
837            module_id: module_id.to_string(),
838            spawn_generation: generation,
839            pid,
840            spawned_at_ms,
841        };
842        state.live.insert(module_id.to_string(), live);
843        Self::emit_locked(
844            &mut state,
845            SpawnEventKind::Spawned,
846            module_id.to_string(),
847            generation,
848            pid,
849            None,
850            None,
851        );
852        generation
853    }
854
855    fn emit_exited(&self, module_id: &str, exit_code: Option<i32>, exit_signal: Option<i32>) {
856        let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
857        let Some(live) = state.live.remove(module_id) else {
858            warn!(
859                module_id,
860                "terminal record had no live spawn event identity"
861            );
862            return;
863        };
864        Self::emit_locked(
865            &mut state,
866            SpawnEventKind::Exited,
867            module_id.to_string(),
868            live.spawn_generation,
869            live.pid,
870            exit_code,
871            exit_signal,
872        );
873    }
874
875    /// Report the exit of a process that a swap has already replaced.
876    ///
877    /// `emit_exited` removes the module's live entry, which after a swap's
878    /// cutover describes the promoted candidate, not the old process now
879    /// exiting. This emits the old generation's exit and leaves the live entry
880    /// alone unless it still names that generation.
881    fn emit_superseded_exited(
882        &self,
883        module_id: &str,
884        spawn_generation: u64,
885        pid: u32,
886        exit_code: Option<i32>,
887        exit_signal: Option<i32>,
888    ) {
889        let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
890        if state
891            .live
892            .get(module_id)
893            .is_some_and(|live| live.spawn_generation == spawn_generation)
894        {
895            state.live.remove(module_id);
896        }
897        Self::emit_locked(
898            &mut state,
899            SpawnEventKind::Exited,
900            module_id.to_string(),
901            spawn_generation,
902            pid,
903            exit_code,
904            exit_signal,
905        );
906    }
907
908    #[allow(clippy::too_many_arguments)]
909    fn emit_locked(
910        state: &mut SpawnEventState,
911        kind: SpawnEventKind,
912        module_id: String,
913        spawn_generation: u64,
914        pid: u32,
915        exit_code: Option<i32>,
916        exit_signal: Option<i32>,
917    ) {
918        state.seq = state
919            .seq
920            .checked_add(1)
921            .expect("spawn event sequence exhausted");
922        let event = SpawnEvent {
923            cursor: Self::cursor(state),
924            kind,
925            module_id,
926            spawn_generation,
927            pid,
928            exit_code,
929            exit_signal,
930        };
931        state.events.push_back(event.clone());
932        while state.events.len() > state.capacity {
933            state.events.pop_front();
934        }
935        let body = match serde_json::to_vec(&event) {
936            Ok(body) => body,
937            Err(error) => {
938                error!(%error, "failed to serialize supervisor spawn event");
939                return;
940            }
941        };
942        state.subscribers.retain(|(connection_id, corr), subscriber| {
943            let frame = Frame::build_with_version(
944                subscriber.version,
945                FrameType::StreamData,
946                control_flags(),
947                0,
948                0,
949                *corr,
950                body.clone(),
951            );
952            match frame {
953                Ok(frame) => {
954                    if subscriber.frames.try_send(frame).is_ok() {
955                        true
956                    } else {
957                        warn!(connection_id = connection_id.get(), corr, "dropping lagged supervisor spawn subscriber");
958                        false
959                    }
960                }
961                Err(error) => {
962                    warn!(connection_id = connection_id.get(), corr, %error, "dropping supervisor spawn subscriber after frame build failure");
963                    false
964                }
965            }
966        });
967    }
968
969    fn subscribe(
970        &self,
971        connection_id: ConnectionId,
972        corr: u64,
973        version: u8,
974        since: Option<SpawnCursor>,
975        sink: FrameSink,
976    ) -> Result<(), SpawnSubscribeRefusal> {
977        let (frames, mut receiver) = mpsc::channel(SPAWN_SUBSCRIBER_BUFFER);
978        {
979            let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
980            let replay = if let Some(since) = since {
981                if since.daemon_incarnation != state.daemon_incarnation {
982                    return Err(SpawnSubscribeRefusal::ForeignIncarnation {
983                        current: state.daemon_incarnation.clone(),
984                    });
985                }
986                if let Some(oldest) = state.events.front().map(|event| event.cursor.clone()) {
987                    if since.seq < oldest.seq.saturating_sub(1) {
988                        return Err(SpawnSubscribeRefusal::TooOld { oldest });
989                    }
990                }
991                state
992                    .events
993                    .iter()
994                    .filter(|event| event.cursor.seq > since.seq)
995                    .cloned()
996                    .collect::<Vec<_>>()
997            } else {
998                Vec::new()
999            };
1000            for event in replay {
1001                let body = serde_json::to_vec(&event)
1002                    .map_err(|error| SpawnSubscribeRefusal::Frame(error.to_string()))?;
1003                let frame = Frame::build_with_version(
1004                    version,
1005                    FrameType::StreamData,
1006                    control_flags(),
1007                    0,
1008                    0,
1009                    corr,
1010                    body,
1011                )
1012                .map_err(|error| SpawnSubscribeRefusal::Frame(error.to_string()))?;
1013                frames
1014                    .try_send(frame)
1015                    .map_err(|error| SpawnSubscribeRefusal::Frame(error.to_string()))?;
1016            }
1017            state.subscribers.insert(
1018                (connection_id, corr),
1019                SpawnSubscriber {
1020                    version,
1021                    frames: frames.clone(),
1022                },
1023            );
1024        }
1025        tokio::spawn(async move {
1026            while let Some(frame) = receiver.recv().await {
1027                if sink.send(frame).await.is_err() {
1028                    break;
1029                }
1030            }
1031        });
1032        Ok(())
1033    }
1034
1035    fn cancel(&self, connection_id: ConnectionId, corr: u64) -> bool {
1036        let Some(subscriber) = self
1037            .0
1038            .lock()
1039            .unwrap_or_else(|p| p.into_inner())
1040            .subscribers
1041            .remove(&(connection_id, corr))
1042        else {
1043            return false;
1044        };
1045        if let Ok(frame) = Frame::build_with_version(
1046            subscriber.version,
1047            FrameType::StreamEnd,
1048            control_flags(),
1049            0,
1050            0,
1051            corr,
1052            Vec::new(),
1053        ) {
1054            tokio::spawn(async move {
1055                let _ = subscriber.frames.send(frame).await;
1056            });
1057        }
1058        true
1059    }
1060
1061    fn remove_connection(&self, connection_id: ConnectionId) {
1062        self.0
1063            .lock()
1064            .unwrap_or_else(|p| p.into_inner())
1065            .subscribers
1066            .retain(|(subscriber_connection, _), _| *subscriber_connection != connection_id);
1067    }
1068
1069    #[cfg(any(test, feature = "test-support"))]
1070    fn set_capacity(&self, capacity: usize) {
1071        self.0.lock().unwrap_or_else(|p| p.into_inner()).capacity = capacity;
1072    }
1073
1074    #[cfg(any(test, feature = "test-support"))]
1075    fn subscriber_count(&self) -> usize {
1076        self.0
1077            .lock()
1078            .unwrap_or_else(|p| p.into_inner())
1079            .subscribers
1080            .len()
1081    }
1082}
1083
1084/// Narrow process-liveness signal published by supervisors and consumed by passive liveness polls.
1085pub trait ModuleProcessLiveness: Send + Sync {
1086    fn process_live(&self, module_id: &str) -> Option<bool>;
1087}
1088
1089/// Shared process-liveness registry keyed by supervised `module_id`.
1090#[derive(Debug, Clone, Default)]
1091pub struct SupervisorProcessLiveness {
1092    snapshots: Arc<Mutex<HashMap<String, SharedSnapshot>>>,
1093}
1094
1095impl SupervisorProcessLiveness {
1096    pub fn new() -> Self {
1097        Self::default()
1098    }
1099
1100    fn track(&self, module_id: String, snapshot: SharedSnapshot) {
1101        let mut snapshots = self
1102            .snapshots
1103            .lock()
1104            .unwrap_or_else(|poisoned| poisoned.into_inner());
1105        snapshots.insert(module_id, snapshot);
1106    }
1107
1108    fn untrack_if_current(&self, module_id: &str, snapshot: &SharedSnapshot) {
1109        let mut snapshots = self
1110            .snapshots
1111            .lock()
1112            .unwrap_or_else(|poisoned| poisoned.into_inner());
1113        let is_current = snapshots
1114            .get(module_id)
1115            .map(|tracked| Arc::ptr_eq(tracked, snapshot))
1116            .unwrap_or(false);
1117        if is_current {
1118            snapshots.remove(module_id);
1119        }
1120    }
1121}
1122
1123impl ModuleProcessLiveness for SupervisorProcessLiveness {
1124    fn process_live(&self, module_id: &str) -> Option<bool> {
1125        let snapshot = {
1126            let snapshots = self
1127                .snapshots
1128                .lock()
1129                .unwrap_or_else(|poisoned| poisoned.into_inner());
1130            snapshots.get(module_id).cloned()
1131        }?;
1132        let snapshot = snapshot
1133            .lock()
1134            .unwrap_or_else(|poisoned| poisoned.into_inner());
1135        Some(snapshot.state == ModuleState::Running && snapshot.process_alive)
1136    }
1137}
1138
1139#[derive(Debug, Clone)]
1140struct SupervisorRuntimeConfig {
1141    restart_policy: RestartPolicy,
1142    /// This module's RESOLVED drain budget: per-module config when present,
1143    /// else `default_drain_timeout`.
1144    drain_timeout: Duration,
1145    /// Shared with the status handle so the attested value changes atomically
1146    /// when a rescan updates the running drain policy.
1147    effective_drain_timeout: Arc<Mutex<Duration>>,
1148    /// The supervisor-wide fallback, kept so a configuration update that
1149    /// REMOVES the per-module override can re-resolve to it.
1150    default_drain_timeout: Duration,
1151    health: HealthConfig,
1152    connection_file_path: Option<PathBuf>,
1153    capture_logs_dir: Option<PathBuf>,
1154    forwarding: Option<Arc<ForwardingTable>>,
1155    /// The shared handle, so every spawn path (initial, restart, reload) records the
1156    /// reserved-module launch nonce the HELLO verifier checks against.
1157    supervisor_handle: Option<SupervisorHandle>,
1158    /// This module's stderr tail, shared with the [`SupervisedModule`] that answers
1159    /// status queries.
1160    ///
1161    /// One ring per module, held across every respawn. The lines explaining an exit
1162    /// are written BEFORE that exit, so a ring recreated per process would be empty
1163    /// exactly when it is asked for.
1164    stderr_ring: Arc<Mutex<StderrRing>>,
1165    terminal_ring: Arc<Mutex<TerminalRing>>,
1166    spawn_events: SpawnEventFeed,
1167    child_roster: ChildRoster,
1168    #[cfg(target_os = "linux")]
1169    cgroup_placement: Option<subc_cgroup::Placement>,
1170    #[cfg(test)]
1171    test_seed_stale_facts_before_enable_spawn: bool,
1172}
1173
1174#[derive(Debug, Clone, PartialEq, Eq)]
1175struct SupervisedConfiguration {
1176    spec: ModuleSpec,
1177    health: HealthConfig,
1178}
1179
1180/// Shared daemon lookup table for supervised module handles.
1181///
1182/// Shared by clone between the [`Supervisor`] (which spawns processes) and the
1183/// channel-0 control handler (which verifies HELLOs and consumer route opens), so
1184/// launch nonces recorded at spawn are checked by the same daemon instance.
1185#[derive(Debug, Clone, Default)]
1186pub struct SupervisorHandle {
1187    modules: Arc<Mutex<HashMap<String, SupervisedModule>>>,
1188    spawn_events: SpawnEventFeed,
1189    /// The current expected launch nonce for each reserved module_id. Set when the
1190    /// supervisor spawns the reserved module; checked when a HELLO claims that id. A
1191    /// non-reserved module never has an entry here and is never nonce-checked.
1192    /// Reserved module ids and the nonce that authorizes their next HELLO.
1193    /// `None` means RESERVED WITH NO LEGITIMATE HOLDER — a reserved module that
1194    /// has never been spawned (e.g. configured `enabled: false`) — and refuses
1195    /// every HELLO. Before this was expressible, a reserved-but-never-spawned id
1196    /// had NO entry and admitted anyone: the reservation protected the nonce
1197    /// holder, not the NAME (found live by CKCRED's canary probe registering
1198    /// against a reserved scratch id).
1199    reserved_nonces: Arc<Mutex<HashMap<String, Option<String>>>>,
1200    /// Module ids removed by an executed rescan and the unix-millisecond removal time.
1201    ///
1202    /// This is deliberately in-memory only: subc is state-free across daemon
1203    /// restarts, and the tombstone only explains the hours-after-removal window
1204    /// while this executing daemon is still alive. Do not persist it in a store.
1205    removal_tombstones: Arc<Mutex<HashMap<String, u64>>>,
1206    /// The current launch nonce for every supervised spawn. This is separate from
1207    /// reserved_nonces because consumer route.open attestation applies to all spawned
1208    /// modules, while HELLO id-squatting protection remains opt-in via `reserved`.
1209    spawn_nonces: Arc<Mutex<HashMap<String, String>>>,
1210    /// Reserved namespace prefixes mapped to the supervised owner module whose
1211    /// current spawn nonce authorizes HELLO claims below the prefix.
1212    ///
1213    /// Per §2.6 this is not a same-user security barrier: a same-user process can
1214    /// read the key file and launch nonce env. Like exact reserved ids, it prevents
1215    /// accidental collisions and lower-trust processes from squatting protected
1216    /// namespaces.
1217    reserved_prefix_owners: Arc<Mutex<HashMap<String, String>>>,
1218    /// Blue/green swaps in progress, by module id. An entry exists from just
1219    /// before the candidate process is spawned until the swap has failed, or
1220    /// has cut over and the old process is gone. While it exists, HELLO for the
1221    /// id is gated on the swap token (see [`Self::swap_hello_admission`]) and
1222    /// consumer attestation accepts both processes' nonces.
1223    swaps: Arc<Mutex<HashMap<String, OpenSwap>>>,
1224    /// Told when a swap promotes its candidate; see [`SwapPromotionObserver`].
1225    promotion_observer: PromotionObserverSlot,
1226    /// Serializes module-set reconciliation with operator lifecycle commands. Without
1227    /// this daemon-wide ordering, a rescan could retire or update a module while a
1228    /// concurrent reload still held its old handle and launch specification.
1229    operation_lock: Arc<AsyncMutex<()>>,
1230}
1231
1232/// Told when a swap has promoted its candidate to be the module's active
1233/// registration.
1234///
1235/// An ordinary HELLO runs the control plane's registration side effects (the
1236/// capability cache, the deny census, the requirement recompute) as it
1237/// registers. A swap candidate's HELLO does not, because it is not routable;
1238/// promotion is when those must run instead, and promotion happens in the
1239/// supervisor, which has no other way into the control handler.
1240pub(crate) trait SwapPromotionObserver: Send + Sync {
1241    fn swap_promoted(&self, registration: &crate::registry::ModuleRegistration);
1242}
1243
1244/// The installed [`SwapPromotionObserver`], held weakly: the observer (the
1245/// control handler) owns this handle, so a strong reference back would be a
1246/// cycle that keeps both alive.
1247#[derive(Clone, Default)]
1248struct PromotionObserverSlot(Arc<Mutex<Option<std::sync::Weak<dyn SwapPromotionObserver>>>>);
1249
1250impl fmt::Debug for PromotionObserverSlot {
1251    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1252        f.write_str("PromotionObserverSlot")
1253    }
1254}
1255
1256/// The nonces of one open swap.
1257#[derive(Debug, Clone)]
1258struct OpenSwap {
1259    /// The launch nonce minted for the candidate process. It is the swap
1260    /// token: the only thing that admits a HELLO into the candidate slot.
1261    candidate_nonce: String,
1262    /// The incumbent's launch nonce, captured when the swap opened. It is kept
1263    /// here because cutover moves the module's recorded spawn nonce to the
1264    /// candidate while the incumbent is still draining and its consumers are
1265    /// still attesting with this one.
1266    incumbent_nonce: Option<String>,
1267    /// Set once a HELLO has been admitted with the swap token, so the token
1268    /// admits one registration and cannot be replayed after cutover empties
1269    /// the candidate slot.
1270    candidate_admitted: bool,
1271}
1272
1273/// What the swap gate says about a HELLO. See
1274/// [`SupervisorHandle::swap_hello_admission`].
1275#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1276pub(crate) enum SwapHelloAdmission {
1277    /// No swap is open for the id (or the HELLO carries the incumbent's own
1278    /// nonce); the ordinary gates decide.
1279    NotSwapping,
1280    /// The HELLO carries the swap token: register it into the candidate slot.
1281    Candidate,
1282    /// A swap is open and the HELLO carries a nonce the supervisor did not
1283    /// mint for this id, no nonce, or a token already used.
1284    Refused,
1285}
1286
1287#[derive(Debug, Clone, PartialEq, Eq)]
1288pub(crate) enum ReservedHelloRejection {
1289    Exact {
1290        module_id: String,
1291    },
1292    Prefix {
1293        prefix: String,
1294        owner_module_id: String,
1295    },
1296}
1297
1298impl SupervisorHandle {
1299    pub fn new() -> Self {
1300        Self::default()
1301    }
1302
1303    pub(crate) fn spawn_snapshot(&self) -> SpawnSnapshot {
1304        self.spawn_events.snapshot()
1305    }
1306
1307    pub(crate) fn subscribe_spawns(
1308        &self,
1309        connection_id: ConnectionId,
1310        corr: u64,
1311        version: u8,
1312        since: Option<SpawnCursor>,
1313        sink: FrameSink,
1314    ) -> Result<(), SpawnSubscribeRefusal> {
1315        self.spawn_events
1316            .subscribe(connection_id, corr, version, since, sink)
1317    }
1318
1319    pub(crate) fn cancel_spawn_subscription(&self, connection_id: ConnectionId, corr: u64) -> bool {
1320        self.spawn_events.cancel(connection_id, corr)
1321    }
1322
1323    pub(crate) fn remove_spawn_subscribers(&self, connection_id: ConnectionId) {
1324        self.spawn_events.remove_connection(connection_id);
1325    }
1326
1327    #[cfg(any(test, feature = "test-support"))]
1328    pub fn set_spawn_event_capacity_for_test(&self, capacity: usize) {
1329        assert!(capacity > 0, "spawn event capacity must be non-zero");
1330        self.spawn_events.set_capacity(capacity);
1331    }
1332
1333    #[cfg(any(test, feature = "test-support"))]
1334    pub fn spawn_subscriber_count_for_test(&self) -> usize {
1335        self.spawn_events.subscriber_count()
1336    }
1337
1338    /// Record the launch nonce from a supervised spawn, replacing any prior nonce so
1339    /// a respawn invalidates stale consumer identities.
1340    pub fn set_spawn_nonce(&self, module_id: &str, nonce: String) {
1341        self.spawn_nonces
1342            .lock()
1343            .unwrap_or_else(|poisoned| poisoned.into_inner())
1344            .insert(module_id.to_string(), nonce);
1345    }
1346
1347    /// Record the launch nonce expected from the next HELLO for a reserved module,
1348    /// replacing any prior nonce (a respawn invalidates the previous one).
1349    pub fn set_reserved_nonce(&self, module_id: &str, nonce: String) {
1350        self.reserved_nonces
1351            .lock()
1352            .unwrap_or_else(|poisoned| poisoned.into_inner())
1353            .insert(module_id.to_string(), Some(nonce));
1354    }
1355
1356    /// Record namespace prefixes owned by a supervised module.
1357    pub fn set_reserved_prefixes(&self, owner_module_id: &str, prefixes: &[String]) {
1358        let mut owners = self
1359            .reserved_prefix_owners
1360            .lock()
1361            .unwrap_or_else(|poisoned| poisoned.into_inner());
1362        owners.retain(|_, owner| owner != owner_module_id);
1363        for prefix in prefixes {
1364            owners.insert(prefix.clone(), owner_module_id.to_string());
1365        }
1366    }
1367
1368    /// The launch nonce most recently minted for a module's spawn, if any.
1369    #[cfg(test)]
1370    pub(crate) fn spawn_nonce(&self, module_id: &str) -> Option<String> {
1371        self.spawn_nonces
1372            .lock()
1373            .unwrap_or_else(|poisoned| poisoned.into_inner())
1374            .get(module_id)
1375            .cloned()
1376    }
1377
1378    fn apply_identity_configuration(&self, spec: &ModuleSpec) {
1379        self.set_reserved_prefixes(&spec.module_id, &spec.reserved_prefixes);
1380        let spawn_nonce = self
1381            .spawn_nonces
1382            .lock()
1383            .unwrap_or_else(|poisoned| poisoned.into_inner())
1384            .get(&spec.module_id)
1385            .cloned();
1386        let mut reserved_nonces = self
1387            .reserved_nonces
1388            .lock()
1389            .unwrap_or_else(|poisoned| poisoned.into_inner());
1390        if spec.reserved {
1391            // `None` (no spawn nonce minted) is INSERTED, not skipped: a
1392            // reserved name whose module has never spawned has no legitimate
1393            // holder, and the entry's absence is what used to leave the name
1394            // open to the first claimant.
1395            reserved_nonces.insert(spec.module_id.clone(), spawn_nonce);
1396        }
1397        drop(reserved_nonces);
1398        // A later unreserved declaration must not silently unreserve an id that
1399        // was retained after its reserved configuration was removed. The explicit
1400        // release ceremony is the only operation that retires that gate.
1401        self.removal_tombstones
1402            .lock()
1403            .unwrap_or_else(|poisoned| poisoned.into_inner())
1404            .remove(&spec.module_id);
1405    }
1406
1407    /// Whether a HELLO claiming `module_id` is authorized. An exact reserved id is
1408    /// authorized only by its expected nonce; otherwise a matching reserved prefix
1409    /// is authorized by the owner module's current spawn nonce. Non-reserved ids
1410    /// with no matching prefix are always authorized.
1411    pub fn reserved_hello_authorized(&self, module_id: &str, presented: Option<&str>) -> bool {
1412        self.reserved_hello_rejection(module_id, presented)
1413            .is_none()
1414    }
1415
1416    pub(crate) fn reserved_hello_rejection(
1417        &self,
1418        module_id: &str,
1419        presented: Option<&str>,
1420    ) -> Option<ReservedHelloRejection> {
1421        let nonces = self
1422            .reserved_nonces
1423            .lock()
1424            .unwrap_or_else(|poisoned| poisoned.into_inner());
1425        if let Some(expected) = nonces.get(module_id) {
1426            // `None` = reserved with no legitimate holder: refuse every
1427            // presentation, because no process can hold a nonce that was never
1428            // minted. Only a real minted nonce admits, in constant time.
1429            let authorized = match expected {
1430                Some(expected) => {
1431                    presented.is_some_and(|p| constant_time_eq(expected.as_bytes(), p.as_bytes()))
1432                }
1433                None => false,
1434            };
1435            if authorized {
1436                return None;
1437            }
1438            return Some(ReservedHelloRejection::Exact {
1439                module_id: module_id.to_string(),
1440            });
1441        }
1442        drop(nonces);
1443
1444        let matched_prefix = self
1445            .reserved_prefix_owners
1446            .lock()
1447            .unwrap_or_else(|poisoned| poisoned.into_inner())
1448            .iter()
1449            .filter(|(prefix, _)| module_id.starts_with(prefix.as_str()))
1450            .max_by_key(|(prefix, _)| prefix.len())
1451            .map(|(prefix, owner)| (prefix.clone(), owner.clone()));
1452        let (prefix, owner_module_id) = matched_prefix?;
1453
1454        let authorized = presented.is_some_and(|presented| {
1455            self.spawn_nonces
1456                .lock()
1457                .unwrap_or_else(|poisoned| poisoned.into_inner())
1458                .get(&owner_module_id)
1459                .is_some_and(|expected| constant_time_eq(expected.as_bytes(), presented.as_bytes()))
1460                // While the owner is being swapped, children started by
1461                // either of its two processes hold that process's nonce.
1462                || self.swap_nonce_matches(&owner_module_id, presented)
1463        });
1464        if authorized {
1465            None
1466        } else {
1467            Some(ReservedHelloRejection::Prefix {
1468                prefix,
1469                owner_module_id,
1470            })
1471        }
1472    }
1473
1474    /// Whether a consumer connection proved it came from a daemon-spawned module.
1475    ///
1476    /// Absence of an expected spawn nonce is a hard failure: consumer_identity is
1477    /// accepted only for module ids the supervisor has spawned.
1478    pub fn spawned_consumer_authorized(&self, module_id: &str, presented: &str) -> bool {
1479        if presented.is_empty() {
1480            return false;
1481        }
1482        let nonces = self
1483            .spawn_nonces
1484            .lock()
1485            .unwrap_or_else(|poisoned| poisoned.into_inner());
1486        let current = nonces
1487            .get(module_id)
1488            .is_some_and(|expected| constant_time_eq(expected.as_bytes(), presented.as_bytes()));
1489        drop(nonces);
1490        // During a swap two processes of the module are alive, and a consumer
1491        // started by either one presents that process's nonce. Accepting only
1492        // the recorded one would fail the incumbent's consumers for the whole
1493        // overlap once cutover moves the record to the candidate.
1494        current || self.swap_nonce_matches(module_id, presented)
1495    }
1496
1497    /// Whether `presented` is either nonce of an open swap for `module_id`.
1498    fn swap_nonce_matches(&self, module_id: &str, presented: &str) -> bool {
1499        let swaps = self
1500            .swaps
1501            .lock()
1502            .unwrap_or_else(|poisoned| poisoned.into_inner());
1503        swaps.get(module_id).is_some_and(|swap| {
1504            constant_time_eq(swap.candidate_nonce.as_bytes(), presented.as_bytes())
1505                || swap.incumbent_nonce.as_deref().is_some_and(|incumbent| {
1506                    constant_time_eq(incumbent.as_bytes(), presented.as_bytes())
1507                })
1508        })
1509    }
1510
1511    /// Open a swap for `module_id` with the candidate's freshly minted nonce.
1512    /// Called before the candidate process exists.
1513    pub(crate) fn open_swap(&self, module_id: &str, candidate_nonce: String) {
1514        let incumbent_nonce = self
1515            .spawn_nonces
1516            .lock()
1517            .unwrap_or_else(|poisoned| poisoned.into_inner())
1518            .get(module_id)
1519            .cloned();
1520        self.swaps
1521            .lock()
1522            .unwrap_or_else(|poisoned| poisoned.into_inner())
1523            .insert(
1524                module_id.to_string(),
1525                OpenSwap {
1526                    candidate_nonce,
1527                    incumbent_nonce,
1528                    candidate_admitted: false,
1529                },
1530            );
1531    }
1532
1533    /// Close the swap for `module_id`, releasing whichever nonce is no longer
1534    /// the module's recorded one.
1535    pub(crate) fn close_swap(&self, module_id: &str) {
1536        self.swaps
1537            .lock()
1538            .unwrap_or_else(|poisoned| poisoned.into_inner())
1539            .remove(module_id);
1540    }
1541
1542    /// Install the observer told about swap promotions, replacing any earlier
1543    /// one.
1544    pub(crate) fn set_swap_promotion_observer(
1545        &self,
1546        observer: std::sync::Weak<dyn SwapPromotionObserver>,
1547    ) {
1548        *self
1549            .promotion_observer
1550            .0
1551            .lock()
1552            .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(observer);
1553    }
1554
1555    /// Tell the installed observer, if it is still alive, that a swap promoted
1556    /// `registration`.
1557    fn notify_swap_promoted(&self, registration: &crate::registry::ModuleRegistration) {
1558        let observer = self
1559            .promotion_observer
1560            .0
1561            .lock()
1562            .unwrap_or_else(|poisoned| poisoned.into_inner())
1563            .as_ref()
1564            .and_then(std::sync::Weak::upgrade);
1565        if let Some(observer) = observer {
1566            observer.swap_promoted(registration);
1567        }
1568    }
1569
1570    /// Whether a swap is open for `module_id`.
1571    pub(crate) fn swap_open(&self, module_id: &str) -> bool {
1572        self.swaps
1573            .lock()
1574            .unwrap_or_else(|poisoned| poisoned.into_inner())
1575            .contains_key(module_id)
1576    }
1577
1578    /// Make the candidate's nonce the module's recorded spawn nonce, as a plain
1579    /// respawn would, once cutover has made the candidate the module's process.
1580    /// The swap stays open so the incumbent's nonce keeps attesting until the
1581    /// incumbent has drained and exited.
1582    fn promote_swap_nonce(&self, module_id: &str, reserved: bool) {
1583        let candidate_nonce = self
1584            .swaps
1585            .lock()
1586            .unwrap_or_else(|poisoned| poisoned.into_inner())
1587            .get(module_id)
1588            .map(|swap| swap.candidate_nonce.clone());
1589        let Some(nonce) = candidate_nonce else {
1590            return;
1591        };
1592        self.set_spawn_nonce(module_id, nonce.clone());
1593        if reserved {
1594            self.set_reserved_nonce(module_id, nonce);
1595        }
1596    }
1597
1598    /// The swap gate for a HELLO claiming `module_id`.
1599    ///
1600    /// This runs BEFORE the reserved-module gate. A reserved module's candidate
1601    /// presents the candidate nonce, which the reserved gate (holding the
1602    /// incumbent's nonce) would refuse as `reserved_module` before swap
1603    /// admission was ever reached. And it applies to unreserved ids too: for an
1604    /// unreserved id the only thing that ever stopped a second process claiming
1605    /// a live id was the `duplicate_module_id` refusal, which is exactly the
1606    /// refusal a swap lifts for its candidate.
1607    ///
1608    /// The incumbent's own nonce falls through to the ordinary gates, which
1609    /// treat it as they always have (a live incumbent is refused as a
1610    /// duplicate). Anything else while a swap is open is refused, including an
1611    /// absent nonce.
1612    pub(crate) fn swap_hello_admission(
1613        &self,
1614        module_id: &str,
1615        presented: Option<&str>,
1616    ) -> SwapHelloAdmission {
1617        let swaps = self
1618            .swaps
1619            .lock()
1620            .unwrap_or_else(|poisoned| poisoned.into_inner());
1621        let Some(swap) = swaps.get(module_id) else {
1622            return SwapHelloAdmission::NotSwapping;
1623        };
1624        let Some(presented) = presented else {
1625            return SwapHelloAdmission::Refused;
1626        };
1627        if constant_time_eq(swap.candidate_nonce.as_bytes(), presented.as_bytes()) {
1628            return if swap.candidate_admitted {
1629                SwapHelloAdmission::Refused
1630            } else {
1631                SwapHelloAdmission::Candidate
1632            };
1633        }
1634        if swap
1635            .incumbent_nonce
1636            .as_deref()
1637            .is_some_and(|incumbent| constant_time_eq(incumbent.as_bytes(), presented.as_bytes()))
1638        {
1639            return SwapHelloAdmission::NotSwapping;
1640        }
1641        SwapHelloAdmission::Refused
1642    }
1643
1644    /// Record that the swap token has registered a candidate, so it admits no
1645    /// second HELLO.
1646    pub(crate) fn mark_swap_candidate_admitted(&self, module_id: &str) {
1647        if let Some(swap) = self
1648            .swaps
1649            .lock()
1650            .unwrap_or_else(|poisoned| poisoned.into_inner())
1651            .get_mut(module_id)
1652        {
1653            swap.candidate_admitted = true;
1654        }
1655    }
1656
1657    /// Test/support lookup for the current launch nonce of a supervised spawn.
1658    pub fn spawn_launch_nonce_for(&self, module_id: &str) -> Option<String> {
1659        self.spawn_nonces
1660            .lock()
1661            .unwrap_or_else(|poisoned| poisoned.into_inner())
1662            .get(module_id)
1663            .cloned()
1664    }
1665
1666    /// Test/support lookup for the HELLO-gating nonce of a reserved module.
1667    pub fn reserved_launch_nonce_for(&self, module_id: &str) -> Option<String> {
1668        self.reserved_nonces
1669            .lock()
1670            .unwrap_or_else(|poisoned| poisoned.into_inner())
1671            .get(module_id)
1672            .cloned()
1673            .flatten()
1674    }
1675
1676    pub fn insert(&self, module: SupervisedModule) -> Option<SupervisedModule> {
1677        let mut modules = self
1678            .modules
1679            .lock()
1680            .unwrap_or_else(|poisoned| poisoned.into_inner());
1681        modules.insert(module.module_id().to_string(), module)
1682    }
1683
1684    pub fn get(&self, module_id: &str) -> Option<SupervisedModule> {
1685        let modules = self
1686            .modules
1687            .lock()
1688            .unwrap_or_else(|poisoned| poisoned.into_inner());
1689        modules.get(module_id).cloned()
1690    }
1691
1692    pub(crate) fn record_late_health_answer(
1693        &self,
1694        module_id: &str,
1695        latency_ms: u64,
1696    ) -> Result<bool, SuperviseError> {
1697        let Some(module) = self.get(module_id) else {
1698            return Ok(false);
1699        };
1700        update_snapshot(&module.inner.snapshot, Some(module_id), |state| {
1701            state.health.late_answer_count = state.health.late_answer_count.saturating_add(1);
1702            state.health.last_late_answer_latency_ms = Some(latency_ms);
1703            // A late answer is an answer: the module served the probe, just past
1704            // the deadline. Leaving the miss streak in place while logging
1705            // "proves the module is alive" is how a CPU-starved module that
1706            // answers every probe a few seconds late still marches to the
1707            // threshold and gets killed — the exact kill class `NoAnswer` is
1708            // excluded from `is_proof_of_death` to prevent. Slow-but-answering
1709            // is degradation, and degradation reports; it does not restart.
1710            state.health.consecutive_failures = 0;
1711        })?;
1712        Ok(true)
1713    }
1714
1715    /// Arm the one-shot marker for the module process that this caller
1716    /// deliberately initiated severance against. Generic connection teardown
1717    /// must not call this:
1718    /// a surviving process would otherwise retain an exemption for a later
1719    /// genuine crash.
1720    pub fn record_deliberate_severance(&self, module_id: &str) -> Result<bool, SuperviseError> {
1721        let Some(module) = self.get(module_id) else {
1722            return Ok(false);
1723        };
1724        let status = module.status()?;
1725        let Some((pid, start_time)) = status.pid.zip(status.process_start_time) else {
1726            return Ok(false);
1727        };
1728        module.record_deliberate_severance(ProcessIdentity { pid, start_time })
1729    }
1730
1731    pub fn list(&self) -> Vec<SupervisedModule> {
1732        let modules = self
1733            .modules
1734            .lock()
1735            .unwrap_or_else(|poisoned| poisoned.into_inner());
1736        let mut modules = modules.values().cloned().collect::<Vec<_>>();
1737        modules.sort_by(|left, right| left.module_id().cmp(right.module_id()));
1738        modules
1739    }
1740
1741    pub(crate) fn retire(&self, module_id: &str) -> Option<SupervisedModule> {
1742        self.spawn_nonces
1743            .lock()
1744            .unwrap_or_else(|poisoned| poisoned.into_inner())
1745            .remove(module_id);
1746        self.close_swap(module_id);
1747        let mut reserved_nonces = self
1748            .reserved_nonces
1749            .lock()
1750            .unwrap_or_else(|poisoned| poisoned.into_inner());
1751        if reserved_nonces.contains_key(module_id) {
1752            // The old nonce must die with the removed process, but the exact-id
1753            // gate remains until an operator explicitly releases it.
1754            reserved_nonces.insert(module_id.to_string(), None);
1755        }
1756        drop(reserved_nonces);
1757        self.reserved_prefix_owners
1758            .lock()
1759            .unwrap_or_else(|poisoned| poisoned.into_inner())
1760            .retain(|_, owner| owner != module_id);
1761        self.modules
1762            .lock()
1763            .unwrap_or_else(|poisoned| poisoned.into_inner())
1764            .remove(module_id)
1765    }
1766
1767    /// Remember a module removed by a non-preview rescan so route.open can
1768    /// distinguish that intentional removal from an unknown id.
1769    pub(crate) fn record_rescan_removal(&self, module_id: &str) {
1770        self.removal_tombstones
1771            .lock()
1772            .unwrap_or_else(|poisoned| poisoned.into_inner())
1773            .insert(module_id.to_string(), unix_ms_now());
1774    }
1775
1776    /// Return how long ago a rescan removed this module in milliseconds.
1777    pub(crate) fn removal_tombstone_age_ms(&self, module_id: &str) -> Option<u64> {
1778        self.removal_tombstones
1779            .lock()
1780            .unwrap_or_else(|poisoned| poisoned.into_inner())
1781            .get(module_id)
1782            .copied()
1783            .map(|removed_at_ms| unix_ms_now().saturating_sub(removed_at_ms))
1784    }
1785
1786    /// Retire a reserved-id gate only after its module has left supervision.
1787    ///
1788    /// A retained gate has no live nonce (`None`), so releasing any other entry
1789    /// would weaken a currently configured or otherwise active reservation.
1790    pub(crate) fn release_retained_reserved_gate(&self, module_id: &str) -> bool {
1791        if self.get(module_id).is_some() {
1792            return false;
1793        }
1794        let mut reserved_nonces = self
1795            .reserved_nonces
1796            .lock()
1797            .unwrap_or_else(|poisoned| poisoned.into_inner());
1798        if !matches!(reserved_nonces.get(module_id), Some(None)) {
1799            return false;
1800        }
1801        reserved_nonces.remove(module_id);
1802        true
1803    }
1804
1805    pub(crate) fn operation_lock(&self) -> Arc<AsyncMutex<()>> {
1806        Arc::clone(&self.operation_lock)
1807    }
1808}
1809
1810/// Process supervisor for subc-owned singleton modules.
1811#[derive(Debug, Clone)]
1812pub struct Supervisor {
1813    registry: Arc<Registry>,
1814    restart_policy: RestartPolicy,
1815    drain_timeout: Duration,
1816    connection_file_path: Option<PathBuf>,
1817    capture_logs_dir: Option<PathBuf>,
1818    forwarding: Option<Arc<ForwardingTable>>,
1819    process_liveness: Arc<SupervisorProcessLiveness>,
1820    supervisor_handle: Option<SupervisorHandle>,
1821    health: HealthConfig,
1822    daemon_start_clock: crate::clock::StartClock,
1823    terminal_journal: Option<Arc<crate::terminal_journal::TerminalJournal>>,
1824    spawn_events: SpawnEventFeed,
1825    provenance_probe: ExecutableIdentityProbe,
1826    /// Every process spawned through this supervisor (and its clones) and not
1827    /// yet reaped, so daemon shutdown can end them.
1828    child_roster: ChildRoster,
1829    #[cfg(target_os = "linux")]
1830    cgroup_placement: Option<subc_cgroup::Placement>,
1831}
1832
1833impl Supervisor {
1834    #[cfg(unix)]
1835    pub(crate) fn stamp_shutdown(&self) {
1836        if let Some(journal) = &self.terminal_journal {
1837            journal.stamp_shutdown();
1838        }
1839    }
1840
1841    /// Announce a cut while established connections can still carry replies.
1842    /// These budgets promise notice and a bounded wait, not child completion;
1843    /// they are local policy, not an estimate of launchd's unknown kill ceiling.
1844    #[cfg(unix)]
1845    pub(crate) async fn drain_for_daemon_shutdown(&self) -> Result<(), SuperviseError> {
1846        const NOTICE_BUDGET: Duration = Duration::from_millis(500);
1847        const DRAIN_BUDGET: Duration = Duration::from_secs(2);
1848        let Some(forwarding) = &self.forwarding else {
1849            return Ok(());
1850        };
1851        let module_ids = forwarding
1852            .begin_daemon_drain()
1853            .map_err(SuperviseError::Forwarding)?;
1854        let deadline_ms =
1855            unix_ms_now().saturating_add((NOTICE_BUDGET + DRAIN_BUDGET).as_millis() as u64);
1856        let mut notices = tokio::task::JoinSet::new();
1857        let mut drains = Vec::new();
1858        for module_id in module_ids {
1859            let Some(target) = forwarding
1860                .begin_module_drain(&module_id, RouteCloseReason::Restart)
1861                .map_err(SuperviseError::Forwarding)?
1862            else {
1863                continue;
1864            };
1865            let routes = forwarding
1866                .endpoint_routes(target.endpoint)
1867                .map_err(SuperviseError::Forwarding)?;
1868            // Restart allows deployed consumers to reopen after the new daemon
1869            // appears. The terminal journal's daemon_shutdown marker distinguishes
1870            // a daemon cut from a module restart without changing wire reasons.
1871            let command = serde_json::to_vec(&ModuleControlCommand::Draining {
1872                reason: RouteCloseReason::Restart,
1873                deadline_ms,
1874            })
1875            .expect("module draining serializes");
1876            let closing = serde_json::to_vec(&ClientControlPush::RouteClosing {
1877                module_id: module_id.clone(),
1878                reason: RouteCloseReason::Restart,
1879            })
1880            .expect("route closing serializes");
1881            let mut recipients = vec![(target.sink.clone(), target.negotiated_ver, command)];
1882            let mut seen = std::collections::HashSet::new();
1883            for route in routes {
1884                let client = route.goodbye_target;
1885                if seen.insert(client.connection_id) {
1886                    recipients.push((client.sink, client.negotiated_ver, closing.clone()));
1887                }
1888            }
1889            for (sink, version, body) in recipients {
1890                notices.spawn(async move {
1891                    let frame = Frame::build_with_version(
1892                        version,
1893                        FrameType::Push,
1894                        control_flags(),
1895                        0,
1896                        0,
1897                        0,
1898                        body,
1899                    )
1900                    .expect("bounded lifecycle notice frame builds");
1901                    sink.send_flushed(frame).await
1902                });
1903            }
1904            let gauges = declared_busy_gauges(&self.registry, &module_id)?;
1905            drains.push((module_id, target.endpoint, gauges));
1906        }
1907        // A quiet forwarding table is not proof that queued notices reached the
1908        // socket. Wait for writer flush acknowledgements before testing quiescence.
1909        let notice_deadline = Instant::now() + NOTICE_BUDGET;
1910        while let Ok(Some(result)) = timeout_at(notice_deadline, notices.join_next()).await {
1911            if !matches!(result, Ok(Ok(()))) {
1912                warn!(?result, "daemon shutdown notice delivery failed");
1913            }
1914        }
1915        notices.abort_all();
1916        let deadline = Instant::now() + DRAIN_BUDGET;
1917        let mut waits = tokio::task::JoinSet::new();
1918        for (module_id, endpoint, gauges) in drains {
1919            let forwarding = Arc::clone(forwarding);
1920            let mut runtime = self.runtime_config();
1921            runtime.health.cadence = Duration::from_millis(100);
1922            waits.spawn(async move {
1923                wait_for_forwarding_quiescence(
1924                    &forwarding,
1925                    &module_id,
1926                    &runtime,
1927                    endpoint,
1928                    deadline,
1929                    &gauges,
1930                    DrainScope::Active,
1931                )
1932                .await
1933            });
1934        }
1935        while let Ok(Some(result)) = timeout_at(deadline, waits.join_next()).await {
1936            if !matches!(result, Ok(Ok(true))) {
1937                warn!(?result, "daemon shutdown drain did not reach quiescence");
1938            }
1939        }
1940        Ok(())
1941    }
1942
1943    /// The last step of an announced daemon shutdown, after the notice and the
1944    /// drain: close every connection so each subc module sees EOF and starts
1945    /// its own teardown, then end every supervised child that has not exited
1946    /// by its own deadline (its drain budget, capped). Modules lead their own
1947    /// process groups, so a
1948    /// service manager's group kill no longer reaches them; without this a
1949    /// child that does not stop on EOF (every `protocol: "none"` child, which
1950    /// has no connection) would outlive the daemon. Every wait is bounded (see
1951    /// `child_roster`), and `escalate` resolving (a second SIGTERM) cuts them.
1952    #[cfg(unix)]
1953    pub(crate) async fn end_children_for_daemon_shutdown(
1954        &self,
1955        already_escalated: bool,
1956        escalate: impl std::future::Future<Output = ()>,
1957    ) {
1958        if let Some(forwarding) = &self.forwarding {
1959            let closed = forwarding.close_all_connections(&CloseReason::new(
1960                "daemon_shutdown",
1961                "the daemon is exiting after its shutdown notice and drain",
1962            ));
1963            debug!(closed, "closed established connections for daemon shutdown");
1964        }
1965        crate::child_roster::end_children_for_daemon_shutdown(
1966            &self.child_roster,
1967            already_escalated,
1968            escalate,
1969        )
1970        .await;
1971    }
1972
1973    pub fn new(registry: Arc<Registry>, restart_policy: RestartPolicy) -> Self {
1974        Self {
1975            registry,
1976            restart_policy,
1977            drain_timeout: DEFAULT_DRAIN_TIMEOUT,
1978            connection_file_path: None,
1979            capture_logs_dir: None,
1980            forwarding: None,
1981            process_liveness: Arc::new(SupervisorProcessLiveness::default()),
1982            supervisor_handle: None,
1983            health: HealthConfig::default(),
1984            daemon_start_clock: crate::clock::StartClock::capture(),
1985            terminal_journal: None,
1986            spawn_events: SpawnEventFeed::default(),
1987            provenance_probe: ExecutableIdentityProbe::default(),
1988            child_roster: ChildRoster::default(),
1989            #[cfg(target_os = "linux")]
1990            cgroup_placement: None,
1991        }
1992    }
1993
1994    pub fn with_drain_timeout(mut self, drain_timeout: Duration) -> Self {
1995        self.drain_timeout = drain_timeout;
1996        self
1997    }
1998
1999    pub fn with_process_liveness(
2000        mut self,
2001        process_liveness: Arc<SupervisorProcessLiveness>,
2002    ) -> Self {
2003        self.process_liveness = process_liveness;
2004        self
2005    }
2006
2007    pub fn with_connection_file_path(mut self, connection_file_path: impl Into<PathBuf>) -> Self {
2008        self.connection_file_path = Some(connection_file_path.into());
2009        self
2010    }
2011
2012    /// Enables daemon-owned capture files for supervised stdout and stderr.
2013    pub fn with_capture_logs_dir(mut self, logs_dir: impl Into<PathBuf>) -> Self {
2014        self.capture_logs_dir = Some(logs_dir.into());
2015        self
2016    }
2017
2018    /// Enables best-effort history shared by every supervised module.
2019    pub fn with_terminal_journal(mut self, path: PathBuf, daemon_incarnation: String) -> Self {
2020        // A millisecond start stamp can repeat after clock rollback or a rapid
2021        // restart. Use the connection file's random daemon_id instead: it already
2022        // identifies this daemon lifetime independently of the wall clock.
2023        self.spawn_events
2024            .configure_incarnation(daemon_incarnation.clone());
2025        self.terminal_journal = Some(Arc::new(crate::terminal_journal::TerminalJournal::open(
2026            path,
2027            daemon_incarnation,
2028        )));
2029        self
2030    }
2031
2032    pub fn with_forwarding(mut self, forwarding: Arc<ForwardingTable>) -> Self {
2033        self.forwarding = Some(forwarding);
2034        self
2035    }
2036
2037    pub fn with_handle(mut self, supervisor_handle: SupervisorHandle) -> Self {
2038        self.spawn_events = supervisor_handle.spawn_events.clone();
2039        self.supervisor_handle = Some(supervisor_handle);
2040        self
2041    }
2042
2043    pub fn with_health_config(mut self, health: HealthConfig) -> Self {
2044        self.health = health;
2045        self
2046    }
2047
2048    #[cfg(target_os = "linux")]
2049    pub fn with_cgroup_placement(
2050        mut self,
2051        cgroup_placement: Option<subc_cgroup::Placement>,
2052    ) -> Self {
2053        self.cgroup_placement = cgroup_placement;
2054        self
2055    }
2056
2057    /// Spawn `spec.program` and start monitoring it.
2058    ///
2059    /// The child is expected to parse `--subc <connection-file-path>`, read the
2060    /// TCP+key connection file, authenticate to the already-running listener, and
2061    /// register with channel-0 `HELLO` using `spec.module_id` as its manifest id.
2062    pub fn spawn(&self, spec: ModuleSpec) -> Result<SupervisedModule, SuperviseError> {
2063        validate_spec(&spec)?;
2064
2065        let runtime = self.runtime_config();
2066        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
2067        let child = spawn_child(
2068            &spec,
2069            runtime.connection_file_path.as_deref(),
2070            self.supervisor_handle.as_ref(),
2071            &runtime.stderr_ring,
2072            runtime.capture_logs_dir.as_deref(),
2073            &runtime.child_roster,
2074            #[cfg(target_os = "linux")]
2075            runtime.cgroup_placement.as_ref(),
2076        )?;
2077        set_running(&snapshot, &child, &spec.module_id, &runtime.spawn_events)?;
2078        self.process_liveness
2079            .track(spec.module_id.clone(), Arc::clone(&snapshot));
2080
2081        Ok(self.supervised_module(spec, runtime, snapshot, Some(child)))
2082    }
2083
2084    /// Start supervising a module declared in daemon configuration.
2085    ///
2086    /// Unlike [`Self::spawn`], this records disabled modules and immediate spawn
2087    /// failures in the supervisor handle so operator-facing `supervisor.list`
2088    /// reflects every configured module while daemon startup continues.
2089    pub fn supervise_configured(
2090        &self,
2091        spec: ModuleSpec,
2092        enabled: bool,
2093    ) -> Result<SupervisedModule, SuperviseError> {
2094        validate_spec(&spec)?;
2095
2096        let runtime = self.runtime_config();
2097        if !enabled {
2098            let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::disabled()));
2099            return Ok(self.supervised_module(spec, runtime, snapshot, None));
2100        }
2101
2102        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
2103        match spawn_child(
2104            &spec,
2105            runtime.connection_file_path.as_deref(),
2106            self.supervisor_handle.as_ref(),
2107            &runtime.stderr_ring,
2108            runtime.capture_logs_dir.as_deref(),
2109            &runtime.child_roster,
2110            #[cfg(target_os = "linux")]
2111            runtime.cgroup_placement.as_ref(),
2112        ) {
2113            Ok(child) => {
2114                set_running(&snapshot, &child, &spec.module_id, &runtime.spawn_events)?;
2115                self.process_liveness
2116                    .track(spec.module_id.clone(), Arc::clone(&snapshot));
2117                Ok(self.supervised_module(spec, runtime, snapshot, Some(child)))
2118            }
2119            Err(err) => {
2120                error!(
2121                    module_id = %spec.module_id,
2122                    program = %spec.program.display(),
2123                    error = %err,
2124                    "configured module failed to spawn; marking failed and continuing"
2125                );
2126                let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::failed()));
2127                Ok(self.supervised_module(spec, runtime, snapshot, None))
2128            }
2129        }
2130    }
2131
2132    /// Supervise a configured module with its own health, drain, and crash
2133    /// budget. The restart policy is per-module because the config file is:
2134    /// `modules.<id>.restart` resolves to a full policy at parse time, and a
2135    /// module that is expensive to restart should not be forced onto the same
2136    /// budget as one that is cheap.
2137    pub fn supervise_configured_with_health(
2138        &self,
2139        spec: ModuleSpec,
2140        enabled: bool,
2141        health: HealthConfig,
2142        drain_timeout_ms: Option<u64>,
2143        restart_policy: RestartPolicy,
2144    ) -> Result<SupervisedModule, SuperviseError> {
2145        validate_spec(&spec)?;
2146
2147        let mut runtime = self.runtime_config();
2148        runtime.health = health;
2149        runtime.restart_policy = restart_policy;
2150        if let Some(ms) = drain_timeout_ms {
2151            runtime.drain_timeout = Duration::from_millis(ms);
2152            *runtime
2153                .effective_drain_timeout
2154                .lock()
2155                .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.drain_timeout;
2156        }
2157        if !enabled {
2158            let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::disabled()));
2159            return Ok(self.supervised_module(spec, runtime, snapshot, None));
2160        }
2161
2162        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
2163        match spawn_child(
2164            &spec,
2165            runtime.connection_file_path.as_deref(),
2166            self.supervisor_handle.as_ref(),
2167            &runtime.stderr_ring,
2168            runtime.capture_logs_dir.as_deref(),
2169            &runtime.child_roster,
2170            #[cfg(target_os = "linux")]
2171            runtime.cgroup_placement.as_ref(),
2172        ) {
2173            Ok(child) => {
2174                set_running(&snapshot, &child, &spec.module_id, &runtime.spawn_events)?;
2175                self.process_liveness
2176                    .track(spec.module_id.clone(), Arc::clone(&snapshot));
2177                Ok(self.supervised_module(spec, runtime, snapshot, Some(child)))
2178            }
2179            Err(err) => {
2180                if health.critical {
2181                    error!(
2182                        module_id = %spec.module_id,
2183                        program = %spec.program.display(),
2184                        error = %err,
2185                        "critical configured module failed to spawn; marking failed and alerting"
2186                    );
2187                } else {
2188                    error!(
2189                        module_id = %spec.module_id,
2190                        program = %spec.program.display(),
2191                        error = %err,
2192                        "configured module failed to spawn; marking failed and continuing"
2193                    );
2194                }
2195                let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::failed()));
2196                Ok(self.supervised_module(spec, runtime, snapshot, None))
2197            }
2198        }
2199    }
2200
2201    fn runtime_config(&self) -> SupervisorRuntimeConfig {
2202        let effective_drain_timeout = Arc::new(Mutex::new(self.drain_timeout));
2203        SupervisorRuntimeConfig {
2204            restart_policy: self.restart_policy,
2205            drain_timeout: self.drain_timeout,
2206            // Shared with this module's roster copy: daemon shutdown waits on
2207            // each child for the module's own drain budget, as resolved now.
2208            child_roster: self
2209                .child_roster
2210                .for_module(Arc::clone(&effective_drain_timeout)),
2211            effective_drain_timeout,
2212            default_drain_timeout: self.drain_timeout,
2213            health: self.health,
2214            connection_file_path: self.connection_file_path.clone(),
2215            capture_logs_dir: self.capture_logs_dir.clone(),
2216            forwarding: self.forwarding.clone(),
2217            supervisor_handle: self.supervisor_handle.clone(),
2218            stderr_ring: Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default()))),
2219            terminal_ring: Arc::new(Mutex::new(
2220                TerminalRing::new(
2221                    TerminalRingConfig::default(),
2222                    self.daemon_start_clock.started_at_ms(),
2223                )
2224                .with_start_clock(self.daemon_start_clock)
2225                .with_journal(self.terminal_journal.clone()),
2226            )),
2227            spawn_events: self.spawn_events.clone(),
2228            #[cfg(target_os = "linux")]
2229            cgroup_placement: self.cgroup_placement.clone(),
2230            #[cfg(test)]
2231            test_seed_stale_facts_before_enable_spawn: false,
2232        }
2233    }
2234
2235    fn supervised_module(
2236        &self,
2237        spec: ModuleSpec,
2238        runtime: SupervisorRuntimeConfig,
2239        snapshot: SharedSnapshot,
2240        child: Option<SupervisedChild>,
2241    ) -> SupervisedModule {
2242        let configuration = Arc::new(Mutex::new(SupervisedConfiguration {
2243            spec: spec.clone(),
2244            health: runtime.health,
2245        }));
2246        let stderr_ring = Arc::clone(&runtime.stderr_ring);
2247        let terminal_ring = Arc::clone(&runtime.terminal_ring);
2248        // The module's OWN policy, which may be its per-module config rather than
2249        // the supervisor-wide one; status must report the budget the supervise
2250        // loop actually enforces.
2251        let restart_policy = runtime.restart_policy;
2252        let effective_drain_timeout = Arc::clone(&runtime.effective_drain_timeout);
2253        let (tx, rx) = mpsc::channel(4);
2254        let monitor = tokio::spawn(supervise_loop(
2255            spec.clone(),
2256            runtime,
2257            Arc::clone(&self.registry),
2258            Arc::clone(&self.process_liveness),
2259            Arc::clone(&snapshot),
2260            child,
2261            rx,
2262        ));
2263
2264        let module_id = spec.module_id.clone();
2265        let module = SupervisedModule {
2266            inner: Arc::new(SupervisedModuleInner {
2267                module_id: module_id.clone(),
2268                registry: Arc::clone(&self.registry),
2269                snapshot,
2270                configuration,
2271                stderr_ring,
2272                terminal_ring,
2273                commands: tx,
2274                monitor: Mutex::new(Some(monitor)),
2275                restart_policy,
2276                effective_drain_timeout,
2277                provenance_probe: self.provenance_probe.clone(),
2278            }),
2279        };
2280        if let Some(supervisor_handle) = &self.supervisor_handle {
2281            supervisor_handle.apply_identity_configuration(&spec);
2282            supervisor_handle.insert(module.clone());
2283        }
2284        module
2285    }
2286}
2287
2288impl Default for Supervisor {
2289    fn default() -> Self {
2290        Self::new(Arc::new(Registry::default()), RestartPolicy::default())
2291    }
2292}
2293
2294/// Handle to one supervised child process.
2295#[derive(Clone)]
2296pub struct SupervisedModule {
2297    inner: Arc<SupervisedModuleInner>,
2298}
2299
2300struct SupervisedModuleInner {
2301    module_id: String,
2302    registry: Arc<Registry>,
2303    snapshot: SharedSnapshot,
2304    configuration: Arc<Mutex<SupervisedConfiguration>>,
2305    stderr_ring: Arc<Mutex<StderrRing>>,
2306    terminal_ring: Arc<Mutex<TerminalRing>>,
2307    commands: mpsc::Sender<SupervisorCommand>,
2308    monitor: Mutex<Option<JoinHandle<()>>>,
2309    /// Copied from the supervisor's runtime config at spawn so `status()` can
2310    /// report the restart budget without reaching back into the supervisor. The
2311    /// policy is fixed for the process's lifetime, so a copy cannot drift.
2312    restart_policy: RestartPolicy,
2313    effective_drain_timeout: Arc<Mutex<Duration>>,
2314    provenance_probe: ExecutableIdentityProbe,
2315}
2316
2317impl fmt::Debug for SupervisedModule {
2318    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2319        f.debug_struct("SupervisedModule")
2320            .field("module_id", &self.inner.module_id)
2321            .field("status", &self.status())
2322            .finish_non_exhaustive()
2323    }
2324}
2325
2326impl SupervisedModule {
2327    pub fn module_id(&self) -> &str {
2328        &self.inner.module_id
2329    }
2330
2331    /// Test-only: put one probe miss on the streak, the way
2332    /// `handle_health_probe_failure` does, so tests can assert what a later
2333    /// event does to the streak without driving the whole probe loop.
2334    #[cfg(test)]
2335    pub(crate) fn record_health_probe_failure_for_test(
2336        &self,
2337        detail: &str,
2338    ) -> Result<(), SuperviseError> {
2339        update_snapshot(&self.inner.snapshot, Some(&self.inner.module_id), |state| {
2340            state.health.consecutive_failures = state.health.consecutive_failures.saturating_add(1);
2341            state.health.detail = Some(detail.to_string());
2342        })
2343    }
2344
2345    pub fn state(&self) -> Result<ModuleState, SuperviseError> {
2346        Ok(lock_snapshot(&self.inner.snapshot)?.state)
2347    }
2348
2349    /// The module's retained stderr, newest lines last.
2350    ///
2351    /// Deliberately NOT on [`Self::status`]: a bounded tail is kilobytes per
2352    /// module, `supervisor.list` renders every module, and putting it in the
2353    /// shared snapshot would make each status read carry a payload almost nobody
2354    /// asked for. Callers that want the text ask for it.
2355    pub fn stderr_tail(
2356        &self,
2357        max_lines: Option<usize>,
2358        max_bytes: Option<usize>,
2359    ) -> StderrTailSnapshot {
2360        self.inner
2361            .stderr_ring
2362            .lock()
2363            .unwrap_or_else(|poisoned| poisoned.into_inner())
2364            .snapshot(max_lines, max_bytes)
2365    }
2366
2367    /// The module's bounded terminal history, oldest retained exit first.
2368    ///
2369    /// The daemon-start stamp distinguishes a quiet supervisor from a replacement
2370    /// daemon whose in-memory history was necessarily reset.
2371    pub fn terminal_history(&self) -> TerminalHistorySnapshot {
2372        self.inner
2373            .terminal_ring
2374            .lock()
2375            .unwrap_or_else(|poisoned| poisoned.into_inner())
2376            .snapshot()
2377    }
2378
2379    /// Retained observations from the current ring and all journal generations.
2380    pub fn durable_terminal_history(&self) -> subc_control::TerminalHistory {
2381        self.inner
2382            .terminal_ring
2383            .lock()
2384            .unwrap_or_else(|p| p.into_inner())
2385            .durable_history(&self.inner.module_id)
2386    }
2387
2388    pub fn status(&self) -> Result<ModuleStatus, SuperviseError> {
2389        self.status_with_snapshot_lock(&self.inner.snapshot, None)
2390    }
2391
2392    pub(crate) fn record_deliberate_severance(
2393        &self,
2394        identity: ProcessIdentity,
2395    ) -> Result<bool, SuperviseError> {
2396        let mut snapshot = lock_snapshot(&self.inner.snapshot)?;
2397        if snapshot.pid != Some(identity.pid)
2398            || snapshot.process_start_time != Some(identity.start_time)
2399        {
2400            return Ok(false);
2401        }
2402        snapshot.deliberate_severance = Some(identity);
2403        Ok(true)
2404    }
2405
2406    /// Read status for a channel-0 renderer and report a contended snapshot lock.
2407    ///
2408    /// Internal supervision callers use [`Self::status`] so writer-side machinery
2409    /// does not produce reader-observability logs.
2410    pub(crate) fn status_for_control(
2411        &self,
2412        caller: &'static str,
2413    ) -> Result<ModuleStatus, SuperviseError> {
2414        self.status_with_snapshot_lock(&self.inner.snapshot, Some(caller))
2415    }
2416
2417    fn status_with_snapshot_lock(
2418        &self,
2419        snapshot: &SharedSnapshot,
2420        caller: Option<&'static str>,
2421    ) -> Result<ModuleStatus, SuperviseError> {
2422        let mut guard = match caller {
2423            Some(caller) => lock_snapshot_for_control(snapshot, &self.inner.module_id, caller)?,
2424            None => lock_snapshot(snapshot)?,
2425        };
2426        // Read the budget through the pruning path so a reader sees the same
2427        // in-window count the restart decision would use, not a stale total.
2428        let restart_count =
2429            guard.crash_restarts_in_window(self.inner.restart_policy.window, Instant::now());
2430        let snapshot = guard.clone();
2431        drop(guard);
2432        let drain_timeout = *self.inner.effective_drain_timeout.lock().map_err(|_| {
2433            SuperviseError::StatePoisoned {
2434                module_id: Some(self.inner.module_id.clone()),
2435            }
2436        })?;
2437        let registration_active = self
2438            .inner
2439            .registry
2440            .get_module(&self.inner.module_id)
2441            .map_err(SuperviseError::Registry)?
2442            .is_some();
2443        let protocol = self.declared_protocol()?;
2444        let running_process =
2445            snapshot.enabled && snapshot.state == ModuleState::Running && snapshot.process_alive;
2446        // Registration is the difference between the two protocols and the only
2447        // one: a subc module that has not registered cannot serve a request even
2448        // though its process is up, and a `none` module never registers at all,
2449        // so requiring it there would pin `live` to false for the whole life of
2450        // a perfectly healthy process.
2451        let live = match protocol {
2452            ModuleProtocol::Subc => running_process && registration_active,
2453            ModuleProtocol::None => running_process,
2454        };
2455
2456        Ok(ModuleStatus {
2457            module_id: self.inner.module_id.clone(),
2458            state: snapshot.state,
2459            enabled: snapshot.enabled,
2460            process_alive: snapshot.process_alive,
2461            registration_active,
2462            protocol,
2463            live,
2464            restart_count,
2465            lifetime_restarts: snapshot.lifetime_restarts,
2466            spawn_generation: snapshot.spawn_generation,
2467            max_restarts: self.inner.restart_policy.max_restarts,
2468            restart_window: self.inner.restart_policy.window,
2469            drain_timeout,
2470            restart_backoff: self.inner.restart_policy.backoff,
2471            restart_max_backoff: self.inner.restart_policy.max_backoff,
2472            pid: snapshot.pid,
2473            spawned_at_ms: snapshot.spawned_at_ms,
2474            spawned_from: snapshot.spawned_from,
2475            process_start_time: snapshot.process_start_time,
2476            last_exit: snapshot.last_exit,
2477            health: snapshot.health,
2478        })
2479    }
2480
2481    #[cfg(test)]
2482    pub(crate) fn hold_snapshot_for_test(
2483        &self,
2484        acquired: std::sync::mpsc::Sender<()>,
2485        hold: Duration,
2486    ) -> std::thread::JoinHandle<()> {
2487        let snapshot = Arc::clone(&self.inner.snapshot);
2488        std::thread::spawn(move || {
2489            let _guard = snapshot.lock().expect("test snapshot lock is not poisoned");
2490            acquired
2491                .send(())
2492                .expect("test receiver waits for snapshot lock");
2493            std::thread::sleep(hold);
2494        })
2495    }
2496
2497    pub(crate) async fn running_image_agreement(&self) -> subc_control::RunningImageAgreement {
2498        let snapshot = match lock_snapshot(&self.inner.snapshot) {
2499            Ok(snapshot) => snapshot.clone(),
2500            Err(_) => {
2501                return subc_control::RunningImageAgreement::Unavailable {
2502                    reason: subc_control::RunningImageUnavailableReason::NotRunning,
2503                };
2504            }
2505        };
2506        self.inner
2507            .provenance_probe
2508            .observe(
2509                snapshot.pid,
2510                snapshot.spawned_from.as_deref(),
2511                snapshot.spawned_file_identity,
2512                snapshot.process_start_time,
2513            )
2514            .await
2515    }
2516
2517    pub(crate) fn will_recover_after_connection_loss(&self) -> Result<bool, SuperviseError> {
2518        let mut snapshot = lock_snapshot(&self.inner.snapshot)?;
2519        Ok(match snapshot.state {
2520            ModuleState::Restarting => true,
2521            ModuleState::Failed | ModuleState::Disabled => false,
2522            _ => daemon_will_restart(&mut snapshot, &self.inner.restart_policy, Instant::now()),
2523        })
2524    }
2525
2526    #[cfg(test)]
2527    pub(crate) fn is_warming(&self) -> Result<bool, SuperviseError> {
2528        self.is_warming_with_snapshot_lock(None)
2529    }
2530
2531    pub(crate) fn is_warming_for_control(
2532        &self,
2533        caller: &'static str,
2534    ) -> Result<bool, SuperviseError> {
2535        self.is_warming_with_snapshot_lock(Some(caller))
2536    }
2537
2538    fn is_warming_with_snapshot_lock(
2539        &self,
2540        caller: Option<&'static str>,
2541    ) -> Result<bool, SuperviseError> {
2542        let snapshot = match caller {
2543            Some(caller) => {
2544                lock_snapshot_for_control(&self.inner.snapshot, &self.inner.module_id, caller)?
2545            }
2546            None => lock_snapshot(&self.inner.snapshot)?,
2547        }
2548        .clone();
2549        Ok(matches!(
2550            snapshot.state,
2551            ModuleState::Starting | ModuleState::Running | ModuleState::Restarting
2552        ))
2553    }
2554
2555    /// Drain the module and stop monitoring it.
2556    pub async fn drain(&self) -> Result<(), SuperviseError> {
2557        self.stop().await
2558    }
2559
2560    pub(crate) async fn retire(&self) -> Result<(), SuperviseError> {
2561        match self.state()? {
2562            ModuleState::Stopped | ModuleState::Failed => return Ok(()),
2563            ModuleState::Starting
2564            | ModuleState::Running
2565            | ModuleState::Unresponsive
2566            | ModuleState::Restarting
2567            | ModuleState::Draining
2568            | ModuleState::Disabled => {}
2569        }
2570
2571        let (reply_tx, reply_rx) = oneshot::channel();
2572        self.inner
2573            .commands
2574            .send(SupervisorCommand::Retire { reply: reply_tx })
2575            .await
2576            .map_err(|_| SuperviseError::CommandClosed {
2577                module_id: self.inner.module_id.clone(),
2578            })?;
2579        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
2580            module_id: self.inner.module_id.clone(),
2581        })?
2582    }
2583
2584    pub async fn stop(&self) -> Result<(), SuperviseError> {
2585        match self.state()? {
2586            ModuleState::Stopped | ModuleState::Failed => return Ok(()),
2587            ModuleState::Starting
2588            | ModuleState::Running
2589            | ModuleState::Unresponsive
2590            | ModuleState::Restarting
2591            | ModuleState::Draining
2592            | ModuleState::Disabled => {}
2593        }
2594
2595        let (reply_tx, reply_rx) = oneshot::channel();
2596        self.inner
2597            .commands
2598            .send(SupervisorCommand::Drain { reply: reply_tx })
2599            .await
2600            .map_err(|_| SuperviseError::CommandClosed {
2601                module_id: self.inner.module_id.clone(),
2602            })?;
2603        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
2604            module_id: self.inner.module_id.clone(),
2605        })?
2606    }
2607
2608    pub async fn restart(&self, drain_timeout_ms: Option<u64>) -> Result<(), SuperviseError> {
2609        let (reply_tx, reply_rx) = oneshot::channel();
2610        self.inner
2611            .commands
2612            .send(SupervisorCommand::Restart {
2613                drain_timeout_ms,
2614                reply: reply_tx,
2615            })
2616            .await
2617            .map_err(|_| SuperviseError::CommandClosed {
2618                module_id: self.inner.module_id.clone(),
2619            })?;
2620        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
2621            module_id: self.inner.module_id.clone(),
2622        })?
2623    }
2624
2625    /// Blue/green restart: see [`SupervisorCommand::Swap`] and the
2626    /// `supervisor_swap` module. Returns once the swap has cut over (the old
2627    /// process then drains in the background of the supervise loop) or has
2628    /// failed, leaving the old process serving.
2629    pub async fn swap(&self, ready_timeout: Option<Duration>) -> Result<(), SuperviseError> {
2630        let (reply_tx, reply_rx) = oneshot::channel();
2631        self.inner
2632            .commands
2633            .send(SupervisorCommand::Swap {
2634                ready_timeout,
2635                reply: reply_tx,
2636            })
2637            .await
2638            .map_err(|_| SuperviseError::CommandClosed {
2639                module_id: self.inner.module_id.clone(),
2640            })?;
2641        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
2642            module_id: self.inner.module_id.clone(),
2643        })?
2644    }
2645
2646    pub async fn reload(&self) -> Result<(), SuperviseError> {
2647        let (reply_tx, reply_rx) = oneshot::channel();
2648        self.inner
2649            .commands
2650            .send(SupervisorCommand::Reload { reply: reply_tx })
2651            .await
2652            .map_err(|_| SuperviseError::CommandClosed {
2653                module_id: self.inner.module_id.clone(),
2654            })?;
2655        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
2656            module_id: self.inner.module_id.clone(),
2657        })?
2658    }
2659
2660    pub async fn set_enabled(&self, enabled: bool) -> Result<bool, SuperviseError> {
2661        let (reply_tx, reply_rx) = oneshot::channel();
2662        self.inner
2663            .commands
2664            .send(SupervisorCommand::SetEnabled {
2665                enabled,
2666                reply: reply_tx,
2667            })
2668            .await
2669            .map_err(|_| SuperviseError::CommandClosed {
2670                module_id: self.inner.module_id.clone(),
2671            })?;
2672        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
2673            module_id: self.inner.module_id.clone(),
2674        })?
2675    }
2676
2677    /// This module's declared protocol, read from the same stored configuration
2678    /// the rescan diff compares and `update_configuration` rewrites, so a status
2679    /// read and the supervise loop can never disagree about which protocol is in
2680    /// force.
2681    pub(crate) fn declared_protocol(&self) -> Result<ModuleProtocol, SuperviseError> {
2682        Ok(self
2683            .inner
2684            .configuration
2685            .lock()
2686            .map_err(|_| SuperviseError::StatePoisoned {
2687                module_id: Some(self.inner.module_id.clone()),
2688            })?
2689            .spec
2690            .protocol)
2691    }
2692
2693    pub(crate) fn configuration(&self) -> Result<(ModuleSpec, HealthConfig), SuperviseError> {
2694        let configuration =
2695            self.inner
2696                .configuration
2697                .lock()
2698                .map_err(|_| SuperviseError::StatePoisoned {
2699                    module_id: Some(self.inner.module_id.clone()),
2700                })?;
2701        Ok((configuration.spec.clone(), configuration.health))
2702    }
2703
2704    /// Replace this module's launch spec, keeping its health and drain policy,
2705    /// the way a rescan does for a changed config entry. The running process is
2706    /// untouched; the next spawn (a restart, or a swap's candidate) uses it.
2707    #[cfg(any(test, feature = "test-support"))]
2708    pub async fn update_spec_for_test(&self, spec: ModuleSpec) -> Result<(), SuperviseError> {
2709        let (_, health) = self.configuration()?;
2710        let drain_timeout_ms = u64::try_from(
2711            self.inner
2712                .effective_drain_timeout
2713                .lock()
2714                .unwrap_or_else(|poisoned| poisoned.into_inner())
2715                .as_millis(),
2716        )
2717        .ok();
2718        self.update_configuration(spec, health, drain_timeout_ms)
2719            .await
2720    }
2721
2722    pub(crate) async fn update_configuration(
2723        &self,
2724        spec: ModuleSpec,
2725        health: HealthConfig,
2726        drain_timeout_ms: Option<u64>,
2727    ) -> Result<(), SuperviseError> {
2728        if spec.module_id != self.inner.module_id {
2729            return Err(SuperviseError::InvalidSpec {
2730                reason: "a supervised module's module_id cannot be changed".to_string(),
2731            });
2732        }
2733        validate_spec(&spec)?;
2734        let (reply_tx, reply_rx) = oneshot::channel();
2735        self.inner
2736            .commands
2737            .send(SupervisorCommand::UpdateConfiguration {
2738                spec: spec.clone(),
2739                health,
2740                drain_timeout_ms,
2741                reply: reply_tx,
2742            })
2743            .await
2744            .map_err(|_| SuperviseError::CommandClosed {
2745                module_id: self.inner.module_id.clone(),
2746            })?;
2747        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
2748            module_id: self.inner.module_id.clone(),
2749        })?;
2750        let mut configuration =
2751            self.inner
2752                .configuration
2753                .lock()
2754                .map_err(|_| SuperviseError::StatePoisoned {
2755                    module_id: Some(self.inner.module_id.clone()),
2756                })?;
2757        configuration.spec = spec;
2758        configuration.health = health;
2759        Ok(())
2760    }
2761}
2762
2763impl Drop for SupervisedModuleInner {
2764    fn drop(&mut self) {
2765        let Ok(mut monitor) = self.monitor.lock() else {
2766            return;
2767        };
2768        if let Some(monitor) = monitor.as_ref().filter(|monitor| !monitor.is_finished()) {
2769            let _ = update_snapshot(&self.snapshot, Some(&self.module_id), |state| {
2770                state.state = ModuleState::Stopped;
2771                clear_current_process_facts(state);
2772            });
2773            monitor.abort();
2774        }
2775        let _ = monitor.take();
2776    }
2777}
2778
2779#[derive(Debug)]
2780enum SupervisorCommand {
2781    Drain {
2782        reply: oneshot::Sender<Result<(), SuperviseError>>,
2783    },
2784    Retire {
2785        reply: oneshot::Sender<Result<(), SuperviseError>>,
2786    },
2787    Restart {
2788        /// Operator override for this one restart's drain budget, in ms. `None`
2789        /// uses the module's configured/default budget; `Some(0)` cuts
2790        /// immediately (wedge bounce: a stuck request never settles, so
2791        /// waiting only delays recovery).
2792        drain_timeout_ms: Option<u64>,
2793        reply: oneshot::Sender<Result<(), SuperviseError>>,
2794    },
2795    Reload {
2796        reply: oneshot::Sender<Result<(), SuperviseError>>,
2797    },
2798    SetEnabled {
2799        enabled: bool,
2800        reply: oneshot::Sender<Result<bool, SuperviseError>>,
2801    },
2802    UpdateConfiguration {
2803        spec: ModuleSpec,
2804        health: HealthConfig,
2805        /// Per-module drain override from the new config; `None` re-resolves to
2806        /// the supervisor-wide default.
2807        drain_timeout_ms: Option<u64>,
2808        reply: oneshot::Sender<()>,
2809    },
2810    Swap {
2811        /// How long the candidate may take to register and declare itself
2812        /// ready. `None` uses [`DEFAULT_SWAP_READY_TIMEOUT`].
2813        ready_timeout: Option<Duration>,
2814        /// Answered at cutover or failure; the incumbent's drain follows.
2815        reply: oneshot::Sender<Result<(), SuperviseError>>,
2816    },
2817}
2818
2819#[derive(Debug)]
2820pub enum SuperviseError {
2821    InvalidSpec {
2822        reason: String,
2823    },
2824    Spawn {
2825        program: PathBuf,
2826        source: io::Error,
2827        cgroup_path: Option<PathBuf>,
2828    },
2829    Cgroup {
2830        module_id: String,
2831        source: io::Error,
2832    },
2833    /// CSPRNG failure generating a reserved module's launch nonce. Fail loud rather
2834    /// than spawn a reserved module without its identity binding.
2835    LaunchNonce {
2836        reason: String,
2837    },
2838    Wait {
2839        module_id: String,
2840        source: io::Error,
2841    },
2842    Kill {
2843        module_id: String,
2844        source: io::Error,
2845    },
2846    Forwarding(ForwardingError),
2847    Registry(RegistryError),
2848    ReloadUnavailable {
2849        module_id: String,
2850        reason: String,
2851    },
2852    /// An operator restart/reload was requested for a module that is currently
2853    /// disabled. Restart/reload cycle a *running* module; a disabled module must
2854    /// be explicitly re-enabled (set_enabled(true)) rather than silently started
2855    /// by a restart, so these commands are rejected instead of re-enabling it.
2856    Disabled {
2857        module_id: String,
2858    },
2859    ReloadFailed {
2860        module_id: String,
2861        reason: String,
2862    },
2863    RegistrationStillActive {
2864        module_id: String,
2865        waited: Duration,
2866    },
2867    StatePoisoned {
2868        module_id: Option<String>,
2869    },
2870    CommandClosed {
2871        module_id: String,
2872    },
2873    /// A restart or reload arrived while a swap's candidate was warming. The
2874    /// swap owns the module until it cuts over or fails; a stop or disable
2875    /// would have aborted it instead.
2876    SwapInProgress {
2877        module_id: String,
2878    },
2879    /// A swap was refused before anything was spawned.
2880    SwapRefused {
2881        module_id: String,
2882        reason: SwapRefusal,
2883    },
2884    /// A swap spawned a candidate and gave up on it. The candidate has been
2885    /// killed and its slot freed; the incumbent was left serving and was never
2886    /// drained, except in the one `CutoverLost` case described on that arm.
2887    SwapFailed {
2888        module_id: String,
2889        arm: SwapFailureArm,
2890        detail: String,
2891        /// How the candidate exited, when it exited on its own before the
2892        /// supervisor gave up on it.
2893        candidate_exit: Option<ExitReport>,
2894    },
2895}
2896
2897/// Why a swap was refused before a candidate was spawned.
2898#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2899pub enum SwapRefusal {
2900    /// The module's config does not declare `overlap: "safe"`.
2901    OverlapExclusive,
2902    /// The module is not registered, so there is no incumbent to keep serving
2903    /// and nothing a swap would improve on; a plain restart is the tool.
2904    NotRegistered,
2905    /// The module does not speak the subc wire, so a candidate could never
2906    /// register or declare itself ready.
2907    ProtocolNone,
2908    /// The supervisor lacks the forwarding table (to cut routes over) or the
2909    /// shared handle (to admit the candidate's HELLO) that a swap needs.
2910    NotConfigured,
2911    /// A swap is already open for this module.
2912    AlreadySwapping,
2913}
2914
2915impl SwapRefusal {
2916    pub fn as_str(self) -> &'static str {
2917        match self {
2918            Self::OverlapExclusive => "overlap_exclusive",
2919            Self::NotRegistered => "not_registered",
2920            Self::ProtocolNone => "protocol_none",
2921            Self::NotConfigured => "not_configured",
2922            Self::AlreadySwapping => "already_swapping",
2923        }
2924    }
2925}
2926
2927/// Which failure arm ended a swap. Every arm but one leaves the incumbent
2928/// serving and undrained; see `CutoverLost`.
2929#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2930pub enum SwapFailureArm {
2931    /// The candidate process could not be started.
2932    SpawnFailed,
2933    /// The candidate did not register within the readiness budget.
2934    NeverRegistered,
2935    /// The candidate registered but did not declare itself ready in time.
2936    NeverReady,
2937    /// The candidate exited before cutover.
2938    CandidateExited,
2939    /// The candidate declared itself ready but failed its health probe.
2940    CandidateUnhealthy,
2941    /// An operator stop, disable or retire arrived while the candidate warmed.
2942    /// The candidate was killed and the operator's command then carried out on
2943    /// the incumbent.
2944    Interrupted,
2945    /// The candidate's connection closed at the moment of cutover. If it
2946    /// closed before forwarding moved, the incumbent is untouched. If it closed
2947    /// between the forwarding and registry halves of cutover, forwarding can no
2948    /// longer route to the incumbent, so the module is restarted plainly.
2949    CutoverLost,
2950}
2951
2952impl SwapFailureArm {
2953    pub fn as_str(self) -> &'static str {
2954        match self {
2955            Self::SpawnFailed => "spawn_failed",
2956            Self::NeverRegistered => "never_registered",
2957            Self::NeverReady => "never_ready",
2958            Self::CandidateExited => "candidate_exited",
2959            Self::CandidateUnhealthy => "candidate_unhealthy",
2960            Self::Interrupted => "interrupted",
2961            Self::CutoverLost => "cutover_lost",
2962        }
2963    }
2964}
2965
2966impl fmt::Display for SuperviseError {
2967    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2968        match self {
2969            Self::InvalidSpec { reason } => write!(f, "invalid module spec: {reason}"),
2970            Self::Spawn {
2971                program,
2972                source,
2973                cgroup_path: Some(cgroup_path),
2974            } => write!(
2975                f,
2976                "failed to place module in cgroup '{}' while spawning '{}': {source}",
2977                cgroup_path.display(),
2978                program.display()
2979            ),
2980            Self::Spawn {
2981                program,
2982                source,
2983                cgroup_path: None,
2984            } => write!(
2985                f,
2986                "failed to spawn module '{}': {source}",
2987                program.display()
2988            ),
2989            Self::Cgroup { module_id, source } => {
2990                write!(
2991                    f,
2992                    "failed to prepare cgroup for module '{module_id}': {source}"
2993                )
2994            }
2995            Self::LaunchNonce { reason } => {
2996                write!(
2997                    f,
2998                    "failed to generate reserved-module launch nonce: {reason}"
2999                )
3000            }
3001            Self::Wait { module_id, source } => {
3002                write!(f, "failed to wait for module '{module_id}': {source}")
3003            }
3004            Self::Kill { module_id, source } => {
3005                write!(f, "failed to kill module '{module_id}': {source}")
3006            }
3007            Self::Forwarding(err) => write!(f, "forwarding error: {err}"),
3008            Self::Registry(err) => write!(f, "registry error: {err}"),
3009            Self::ReloadUnavailable { module_id, reason } => {
3010                write!(f, "reload unavailable for module '{module_id}': {reason}")
3011            }
3012            Self::Disabled { module_id } => {
3013                write!(
3014                    f,
3015                    "module '{module_id}' is disabled; enable it before restart or reload"
3016                )
3017            }
3018            Self::ReloadFailed { module_id, reason } => {
3019                write!(f, "reload failed for module '{module_id}': {reason}")
3020            }
3021            Self::RegistrationStillActive { module_id, waited } => write!(
3022                f,
3023                "module '{module_id}' registration remained active after waiting {waited:?}"
3024            ),
3025            Self::StatePoisoned { module_id } => match module_id {
3026                Some(module_id) => {
3027                    write!(f, "supervisor state for module '{module_id}' was poisoned")
3028                }
3029                None => write!(f, "supervisor state was poisoned"),
3030            },
3031            Self::CommandClosed { module_id } => {
3032                write!(
3033                    f,
3034                    "supervisor command channel for module '{module_id}' is closed"
3035                )
3036            }
3037            Self::SwapInProgress { module_id } => write!(
3038                f,
3039                "module '{module_id}' is being swapped; retry once the swap has cut over or failed, or stop the module to abort the swap"
3040            ),
3041            Self::SwapRefused { module_id, reason } => match reason {
3042                SwapRefusal::OverlapExclusive => write!(
3043                    f,
3044                    "module '{module_id}' is declared overlap: \"exclusive\" (the default): two processes of it must not run at once, so it cannot be swapped; use a plain restart, or declare overlap: \"safe\" in its config if it really tolerates a second process"
3045                ),
3046                SwapRefusal::NotRegistered => write!(
3047                    f,
3048                    "module '{module_id}' is not registered, so there is no serving process to keep while a replacement warms; use a plain restart"
3049                ),
3050                SwapRefusal::ProtocolNone => write!(
3051                    f,
3052                    "module '{module_id}' is protocol: \"none\" and never registers, so a swap could never see its replacement become ready; use a plain restart"
3053                ),
3054                SwapRefusal::NotConfigured => write!(
3055                    f,
3056                    "module '{module_id}' cannot be swapped: the supervisor was built without the forwarding table or shared handle a swap needs"
3057                ),
3058                SwapRefusal::AlreadySwapping => {
3059                    write!(f, "module '{module_id}' is already being swapped")
3060                }
3061            },
3062            Self::SwapFailed {
3063                module_id,
3064                arm,
3065                detail,
3066                ..
3067            } => write!(
3068                f,
3069                "swap of module '{module_id}' failed ({}): {detail}; the running process was left serving",
3070                arm.as_str()
3071            ),
3072        }
3073    }
3074}
3075
3076impl Error for SuperviseError {
3077    fn source(&self) -> Option<&(dyn Error + 'static)> {
3078        match self {
3079            Self::Spawn { source, .. }
3080            | Self::Cgroup { source, .. }
3081            | Self::Wait { source, .. }
3082            | Self::Kill { source, .. } => Some(source),
3083            Self::Forwarding(err) => Some(err),
3084            Self::Registry(err) => Some(err),
3085            Self::LaunchNonce { .. }
3086            | Self::InvalidSpec { .. }
3087            | Self::ReloadUnavailable { .. }
3088            | Self::Disabled { .. }
3089            | Self::ReloadFailed { .. }
3090            | Self::RegistrationStillActive { .. }
3091            | Self::StatePoisoned { .. }
3092            | Self::CommandClosed { .. }
3093            | Self::SwapInProgress { .. }
3094            | Self::SwapRefused { .. }
3095            | Self::SwapFailed { .. } => None,
3096        }
3097    }
3098}
3099
3100pub(crate) fn validate_spec(spec: &ModuleSpec) -> Result<(), SuperviseError> {
3101    if spec.module_id.trim().is_empty() {
3102        return Err(SuperviseError::InvalidSpec {
3103            reason: "module_id must not be empty".to_string(),
3104        });
3105    }
3106
3107    Ok(())
3108}
3109
3110#[derive(Debug, Default)]
3111struct HealthProbeRuntime {
3112    registered_connection: Option<crate::ConnectionId>,
3113    advertised: bool,
3114    next_probe_at: Option<Instant>,
3115    probe_index: u64,
3116}
3117
3118impl HealthProbeRuntime {
3119    fn refresh_registration(
3120        &mut self,
3121        spec: &ModuleSpec,
3122        runtime: &SupervisorRuntimeConfig,
3123        registry: &Registry,
3124        snapshot: &SharedSnapshot,
3125    ) {
3126        // THE PROBE GATE FOR A MODULE THAT SPEAKS NO SUBC WIRE, placed here
3127        // because this is the only place that ever arms a probe: leaving
3128        // `advertised` false and `next_probe_at` empty makes `due()` false
3129        // forever, so `run_health_probe_cycle` -- and with it every arm of
3130        // `probe_module_health`, including the one that reads an absent
3131        // registration as proof the module is gone and escalates to a restart --
3132        // is unreachable for this module.
3133        //
3134        // That arm is right for a subc module and is exactly wrong here: a
3135        // `protocol: "none"` module never registers by declaration, so the
3136        // absence it would classify is the module working as configured.
3137        if spec.protocol == ModuleProtocol::None {
3138            self.registered_connection = None;
3139            self.advertised = false;
3140            self.next_probe_at = None;
3141            return;
3142        }
3143
3144        let registration = match registry.get_module(&spec.module_id) {
3145            Ok(registration) => registration,
3146            Err(err) => {
3147                warn!(module_id = %spec.module_id, error = %err, "health prober could not read registry");
3148                self.advertised = false;
3149                self.next_probe_at = None;
3150                return;
3151            }
3152        };
3153
3154        let Some(registration) = registration else {
3155            self.registered_connection = None;
3156            self.advertised = false;
3157            self.next_probe_at = None;
3158            return;
3159        };
3160
3161        let advertised = registration
3162            .control_ops
3163            .iter()
3164            .any(|op| op == MODULE_CONTROL_OP_HEALTH_CHECK);
3165        if !advertised {
3166            self.registered_connection = Some(registration.connection_id);
3167            self.advertised = false;
3168            self.next_probe_at = None;
3169            let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
3170                state.health.status = SupervisorHealthStatus::Unknown;
3171                state.health.consecutive_failures = 0;
3172                state.health.last_probe_ms = None;
3173                state.health.detail = None;
3174                state.health.metrics = None;
3175            });
3176            return;
3177        }
3178
3179        let reregistered = self.registered_connection != Some(registration.connection_id);
3180        self.registered_connection = Some(registration.connection_id);
3181        self.advertised = true;
3182        if reregistered || self.next_probe_at.is_none() {
3183            self.probe_index = 0;
3184            self.next_probe_at = Some(
3185                Instant::now() + jittered_health_delay(&spec.module_id, 0, runtime.health.cadence),
3186            );
3187            let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
3188                state.health.status = SupervisorHealthStatus::Unknown;
3189                state.health.consecutive_failures = 0;
3190                state.health.detail = None;
3191                state.health.metrics = None;
3192            });
3193        }
3194    }
3195
3196    fn wake_after(&self) -> Duration {
3197        if !self.advertised {
3198            return REGISTRY_RELEASE_POLL;
3199        }
3200        self.next_probe_at
3201            .map(|next| next.saturating_duration_since(Instant::now()))
3202            .unwrap_or(REGISTRY_RELEASE_POLL)
3203    }
3204
3205    fn due(&self) -> bool {
3206        self.advertised
3207            && self
3208                .next_probe_at
3209                .is_some_and(|next| Instant::now() >= next)
3210    }
3211
3212    fn schedule_next(&mut self, spec: &ModuleSpec, cadence: Duration) {
3213        self.probe_index = self.probe_index.wrapping_add(1);
3214        self.next_probe_at = Some(
3215            Instant::now() + jittered_health_delay(&spec.module_id, self.probe_index, cadence),
3216        );
3217    }
3218}
3219
3220/// What a failed health probe actually OBSERVED, kept apart from how it reads.
3221///
3222/// This was a struct with a single `message: String`, and every one of the
3223/// fifteen construction sites collapsed into it. Each site knows exactly what it
3224/// saw -- the lane is gone, the module did not answer in time, the module
3225/// answered with the wrong thing -- and `handle_health_probe_failure` then
3226/// treated all of them identically: increment a counter, compare to a threshold,
3227/// restart the module. THE DISTINCTION EXISTED AT EVERY CALL SITE AND WAS
3228/// DESTROYED BEFORE THE DECISION THAT NEEDED IT.
3229///
3230/// The distinction that matters is not severity, it is EVIDENTIAL WEIGHT:
3231///
3232/// * `LaneDead` is PROOF. The module's control connection is gone; nothing will
3233///   answer on it again.
3234/// * `NoAnswer` is ABSENCE OF EVIDENCE. It is consistent with a wedged module
3235///   AND with a perfectly healthy one that lost a CPU race -- which is what
3236///   happens under machine load, and is how this supervisor killed a healthy
3237///   module three times in one day.
3238/// * `BadAnswer` proves the module is ALIVE. It replied; the reply was wrong.
3239///   Restarting on it is defensible, but it is not the silence case and should
3240///   never be counted as one.
3241/// * `Misconfigured` is a daemon-side fault. The module has not been asked
3242///   anything, so it cannot be evidence about the module at all.
3243///
3244/// The asymmetry is the whole point: under saturation the WEAKEST signal is the
3245/// one that fires most often, and while every variant collapsed into one string
3246/// it carried the same weight as the strongest.
3247///
3248/// LIVE BEHAVIOUR TODAY, stated here because this doc block describes the
3249/// DESIGN and a reader stopping at it gets the build backwards: the restart
3250/// decision does NOT yet consult this classification -- consecutive `NoAnswer`
3251/// probes still increment the failure streak and drive escalation at the
3252/// threshold (see `is_proof_of_death` below for why that is deliberate and
3253/// what gates the change). Absence of evidence restarts modules today.
3254#[derive(Debug)]
3255enum HealthProbeEvidence {
3256    /// The module's control lane is gone. Proof of death.
3257    LaneDead,
3258    /// No reply within the deadline. Proves nothing about the module's state.
3259    NoAnswer,
3260    /// The module replied, but not with a usable health report. Proves it is alive.
3261    BadAnswer,
3262    /// The daemon could not ask. Says nothing about the module.
3263    Misconfigured,
3264}
3265
3266#[derive(Debug)]
3267struct HealthProbeError {
3268    evidence: HealthProbeEvidence,
3269    message: String,
3270}
3271
3272impl HealthProbeError {
3273    fn lane_dead(message: impl Into<String>) -> Self {
3274        Self::with(HealthProbeEvidence::LaneDead, message)
3275    }
3276
3277    fn no_answer(message: impl Into<String>) -> Self {
3278        Self::with(HealthProbeEvidence::NoAnswer, message)
3279    }
3280
3281    fn bad_answer(message: impl Into<String>) -> Self {
3282        Self::with(HealthProbeEvidence::BadAnswer, message)
3283    }
3284
3285    fn misconfigured(message: impl Into<String>) -> Self {
3286        Self::with(HealthProbeEvidence::Misconfigured, message)
3287    }
3288
3289    fn with(evidence: HealthProbeEvidence, message: impl Into<String>) -> Self {
3290        Self {
3291            evidence,
3292            message: message.into(),
3293        }
3294    }
3295
3296    /// Whether this observation is proof the module cannot serve.
3297    ///
3298    /// Only `LaneDead` qualifies. `NoAnswer` is deliberately excluded: it is the
3299    /// variant that fires under CPU starvation, and treating it as proof is the
3300    /// defect this enum exists to make impossible to reintroduce silently.
3301    ///
3302    /// NOT YET CONSULTED BY THE RESTART DECISION, deliberately. Requiring proof
3303    /// to restart also needs a bound for the case it excludes -- a genuinely
3304    /// wedged module, alive but never answering -- and that bound must come from
3305    /// the distribution of real late-answer latencies, which nothing measures
3306    /// yet. Landing the classification first makes the later change a one-line
3307    /// decision against evidence that already exists, rather than two unproven
3308    /// changes at once.
3309    #[allow(dead_code)]
3310    fn is_proof_of_death(&self) -> bool {
3311        matches!(self.evidence, HealthProbeEvidence::LaneDead)
3312    }
3313
3314    /// Short stable label for logs and the health snapshot.
3315    ///
3316    /// An operator reading `ck health` currently cannot tell "the module is gone"
3317    /// from "the module did not answer in five seconds", because both render as
3318    /// prose in the same field. These labels are what make the two
3319    /// distinguishable at a glance, and they are what a later restart-policy
3320    /// change will be argued from.
3321    fn label(&self) -> &'static str {
3322        match self.evidence {
3323            HealthProbeEvidence::LaneDead => "lane-dead",
3324            HealthProbeEvidence::NoAnswer => "no-answer",
3325            HealthProbeEvidence::BadAnswer => "bad-answer",
3326            HealthProbeEvidence::Misconfigured => "daemon-misconfigured",
3327        }
3328    }
3329}
3330
3331impl fmt::Display for HealthProbeError {
3332    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3333        f.write_str(&self.message)
3334    }
3335}
3336
3337async fn run_health_probe_cycle(
3338    spec: &ModuleSpec,
3339    runtime: &SupervisorRuntimeConfig,
3340    registry: &Registry,
3341    process_liveness: &SupervisorProcessLiveness,
3342    snapshot: &SharedSnapshot,
3343    child: &mut Option<SupervisedChild>,
3344) {
3345    let now_ms = unix_ms_now();
3346    match probe_module_health(&spec.module_id, runtime, None).await {
3347        Ok(report) => {
3348            handle_health_report(
3349                spec,
3350                runtime,
3351                registry,
3352                process_liveness,
3353                snapshot,
3354                child,
3355                report,
3356                now_ms,
3357            )
3358            .await;
3359        }
3360        Err(err) => {
3361            handle_health_probe_failure(
3362                spec,
3363                runtime,
3364                registry,
3365                process_liveness,
3366                snapshot,
3367                child,
3368                err,
3369                now_ms,
3370            )
3371            .await;
3372        }
3373    }
3374}
3375
3376async fn probe_module_health(
3377    module_id: &str,
3378    runtime: &SupervisorRuntimeConfig,
3379    drain_deadline: Option<Instant>,
3380) -> Result<HealthReport, HealthProbeError> {
3381    let Some(forwarding) = runtime.forwarding.as_ref() else {
3382        return Err(HealthProbeError::misconfigured(
3383            "supervisor was not configured with a forwarding table",
3384        ));
3385    };
3386    let probe_started_at = Instant::now();
3387    let mut deadline = probe_started_at + runtime.health.deadline;
3388    if let Some(drain_deadline) = drain_deadline {
3389        deadline = deadline.min(drain_deadline);
3390    }
3391    let pending = if drain_deadline.is_some() {
3392        forwarding.begin_drain_health_probe_rpc_for(
3393            module_id,
3394            MODULE_CONTROL_OP_HEALTH_CHECK,
3395            probe_started_at,
3396            deadline,
3397        )
3398    } else {
3399        forwarding.begin_health_probe_rpc_for(
3400            module_id,
3401            MODULE_CONTROL_OP_HEALTH_CHECK,
3402            probe_started_at,
3403            deadline,
3404        )
3405    }
3406    .map_err(|err| {
3407        // The endpoint is not registered, so there is no live control lane to
3408        // ask. That is the module being absent, not slow.
3409        HealthProbeError::lane_dead(format!("failed to begin health.check RPC: {err}"))
3410    })?;
3411    await_health_probe(forwarding, pending, deadline, runtime.health.deadline).await
3412}
3413
3414/// [`probe_module_health`] for one endpoint rather than the id's active one.
3415///
3416/// A swap probes two processes that no by-id lookup reaches: its candidate
3417/// before cutover, and its superseded incumbent (for busy gauges) while the
3418/// incumbent drains. `deadline_cap` bounds the probe the way a drain deadline
3419/// bounds the by-id drain probe.
3420async fn probe_endpoint_health(
3421    endpoint: crate::ModuleEndpointId,
3422    runtime: &SupervisorRuntimeConfig,
3423    deadline_cap: Option<Instant>,
3424) -> Result<HealthReport, HealthProbeError> {
3425    let Some(forwarding) = runtime.forwarding.as_ref() else {
3426        return Err(HealthProbeError::misconfigured(
3427            "supervisor was not configured with a forwarding table",
3428        ));
3429    };
3430    let probe_started_at = Instant::now();
3431    let mut deadline = probe_started_at + runtime.health.deadline;
3432    if let Some(cap) = deadline_cap {
3433        deadline = deadline.min(cap);
3434    }
3435    let pending = forwarding
3436        .begin_endpoint_health_probe_rpc_for(
3437            endpoint,
3438            MODULE_CONTROL_OP_HEALTH_CHECK,
3439            probe_started_at,
3440            deadline,
3441        )
3442        .map_err(|err| {
3443            HealthProbeError::lane_dead(format!("failed to begin health.check RPC: {err}"))
3444        })?;
3445    await_health_probe(forwarding, pending, deadline, runtime.health.deadline).await
3446}
3447
3448/// Send a begun health probe and classify its answer.
3449async fn await_health_probe(
3450    forwarding: &ForwardingTable,
3451    pending: PendingModuleControlRpc,
3452    deadline: Instant,
3453    probe_budget: Duration,
3454) -> Result<HealthReport, HealthProbeError> {
3455    let PendingModuleControlRpc {
3456        endpoint,
3457        module_sink,
3458        negotiated_ver,
3459        corr,
3460        receiver,
3461    } = pending;
3462    let body = serde_json::to_vec(&ModuleControlRequest::HealthCheck {}).map_err(|err| {
3463        HealthProbeError::misconfigured(format!("failed to encode health.check: {err}"))
3464    })?;
3465    let frame = Frame::build_with_version(
3466        negotiated_ver,
3467        FrameType::Request,
3468        control_flags(),
3469        0,
3470        0,
3471        corr,
3472        body,
3473    )
3474    .map_err(|err| {
3475        HealthProbeError::misconfigured(format!("failed to build health.check frame: {err}"))
3476    })?;
3477
3478    // The enqueue itself must be bounded by the probe deadline: FrameSink.send
3479    // blocks waiting for capacity when the module's egress queue is full, and an
3480    // unbounded await here freezes the whole supervision actor (it stops polling
3481    // Child::wait and supervisor commands), making the module unrecoverable
3482    // in-band. On timeout the probe fails like any transport failure.
3483    match timeout_at(deadline, module_sink.send(frame)).await {
3484        Ok(Ok(())) => {}
3485        Ok(Err(err)) => {
3486            let _ = forwarding.cancel_module_control_rpc(endpoint, corr);
3487            // A closed sink means the module's egress channel is gone -- the
3488            // receiving half is dropped when its connection tears down. Proof.
3489            return Err(HealthProbeError::lane_dead(format!(
3490                "failed to send health.check: {err}"
3491            )));
3492        }
3493        Err(_elapsed) => {
3494            let _ = forwarding.cancel_module_control_rpc(endpoint, corr);
3495            // A full egress queue means the module is not draining its socket, which
3496            // is consistent with a wedged module AND with one whose reader is merely
3497            // starved. Silence, not proof.
3498            return Err(HealthProbeError::no_answer(
3499                "health.check send timed out before enqueue (module egress full)",
3500            ));
3501        }
3502    }
3503
3504    match timeout_at(deadline, receiver).await {
3505        // Each arm records WHAT WAS OBSERVED. Four of them are the module
3506        // demonstrably answering -- rejected, non-health, malformed, wrong op --
3507        // and those prove it is alive even though the probe failed.
3508        Ok(Ok(ModuleControlRpcOutcome::Response(response))) => {
3509            response.health_report().ok_or_else(|| {
3510                HealthProbeError::bad_answer("health.check RPC returned a non-health response")
3511            })
3512        }
3513        Ok(Ok(ModuleControlRpcOutcome::Rejected(body))) => Err(HealthProbeError::bad_answer(
3514            format!("health.check rejected: {}", body.message),
3515        )),
3516        Ok(Ok(ModuleControlRpcOutcome::ModuleGone(message))) => {
3517            Err(HealthProbeError::lane_dead(message))
3518        }
3519        Ok(Ok(ModuleControlRpcOutcome::MalformedResponse(message))) => {
3520            Err(HealthProbeError::bad_answer(message))
3521        }
3522        Ok(Ok(ModuleControlRpcOutcome::UnexpectedOp { expected, actual })) => {
3523            Err(HealthProbeError::bad_answer(format!(
3524                "expected module-control op '{expected}', got '{actual}'"
3525            )))
3526        }
3527        // A reply that crosses the deadline before this waiter observes it is
3528        // still proof of life. The forwarding path records its end-to-end latency
3529        // before delivering this classification.
3530        Ok(Ok(ModuleControlRpcOutcome::DeadlineElapsed)) => Err(HealthProbeError::bad_answer(
3531            "module answered health.check after its daemon deadline",
3532        )),
3533        Ok(Err(_)) => Err(HealthProbeError::misconfigured(
3534            "health.check waiter was canceled before the module responded",
3535        )),
3536        Err(_) => {
3537            let _ = forwarding.tombstone_health_probe_rpc(endpoint, corr);
3538            Err(HealthProbeError::no_answer(format!(
3539                "module did not answer health.check within {probe_budget:?}"
3540            )))
3541        }
3542    }
3543}
3544
3545#[allow(clippy::too_many_arguments)]
3546async fn handle_health_report(
3547    spec: &ModuleSpec,
3548    runtime: &SupervisorRuntimeConfig,
3549    registry: &Registry,
3550    process_liveness: &SupervisorProcessLiveness,
3551    snapshot: &SharedSnapshot,
3552    child: &mut Option<SupervisedChild>,
3553    report: HealthReport,
3554    now_ms: u64,
3555) {
3556    let status = supervisor_health_status(report.status);
3557    let detail = report.detail.clone();
3558    let metrics = truncate_health_metrics(report.metrics);
3559    let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
3560        state.health.status = status;
3561        state.health.last_probe_ms = Some(now_ms);
3562        state.health.detail = detail.clone();
3563        state.health.metrics = metrics.clone();
3564        state.health.consecutive_failures = 0;
3565    });
3566
3567    let action = match report.status {
3568        HealthStatus::Ok => return,
3569        HealthStatus::Degraded => runtime.health.on_degraded,
3570        HealthStatus::Failing => runtime.health.on_failing,
3571    };
3572    apply_l3_health_action(
3573        spec,
3574        runtime,
3575        registry,
3576        process_liveness,
3577        snapshot,
3578        child,
3579        status,
3580        detail.as_deref(),
3581        action,
3582        now_ms,
3583    )
3584    .await;
3585}
3586
3587#[allow(clippy::too_many_arguments)]
3588async fn handle_health_probe_failure(
3589    spec: &ModuleSpec,
3590    runtime: &SupervisorRuntimeConfig,
3591    registry: &Registry,
3592    process_liveness: &SupervisorProcessLiveness,
3593    snapshot: &SharedSnapshot,
3594    child: &mut Option<SupervisedChild>,
3595    err: HealthProbeError,
3596    now_ms: u64,
3597) {
3598    let threshold = runtime.health.failure_threshold.max(1);
3599    let mut failures = 0;
3600    // Carry the evidence class into the operator-visible detail. Without it,
3601    // "module did not answer within 5s" and "the control lane is gone" are two
3602    // prose strings in the same field, and the reader has to know the codebase to
3603    // tell which one is proof of anything.
3604    let detail = format!("[{}] {err}", err.label());
3605    let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
3606        state.health.last_probe_ms = Some(now_ms);
3607        state.health.consecutive_failures = state.health.consecutive_failures.saturating_add(1);
3608        state.health.detail = Some(detail.clone());
3609        state.health.metrics = None;
3610        failures = state.health.consecutive_failures;
3611    });
3612
3613    if failures < threshold {
3614        warn!(
3615            module_id = %spec.module_id,
3616            consecutive_failures = failures,
3617            threshold,
3618            evidence = err.label(),
3619            detail = %detail,
3620            "health.check probe failed"
3621        );
3622        return;
3623    }
3624
3625    let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
3626        state.state = ModuleState::Unresponsive;
3627        state.health.status = SupervisorHealthStatus::Unresponsive;
3628    });
3629    // The evidence class is logged at the kill site because this is the line an
3630    // operator reads after an unexplained restart. A streak of `no-answer` under
3631    // machine load is the known false-positive shape; a `lane-dead` is not.
3632    if runtime.health.critical {
3633        error!(
3634            module_id = %spec.module_id,
3635            status = "unresponsive",
3636            evidence = err.label(),
3637            detail = %detail,
3638            "critical module health alert"
3639        );
3640    } else {
3641        warn!(
3642            module_id = %spec.module_id,
3643            status = "unresponsive",
3644            evidence = err.label(),
3645            detail = %detail,
3646            "module health threshold breached"
3647        );
3648    }
3649    if let Err(err) = health_restart_child(
3650        spec,
3651        runtime,
3652        registry,
3653        process_liveness,
3654        snapshot,
3655        child,
3656        SupervisorHealthStatus::Unresponsive,
3657        Some(&detail),
3658        now_ms,
3659    )
3660    .await
3661    {
3662        error!(module_id = %spec.module_id, error = %err, "health-triggered restart failed");
3663    }
3664}
3665
3666#[allow(clippy::too_many_arguments)]
3667async fn apply_l3_health_action(
3668    spec: &ModuleSpec,
3669    runtime: &SupervisorRuntimeConfig,
3670    registry: &Registry,
3671    process_liveness: &SupervisorProcessLiveness,
3672    snapshot: &SharedSnapshot,
3673    child: &mut Option<SupervisedChild>,
3674    status: SupervisorHealthStatus,
3675    detail: Option<&str>,
3676    action: HealthAction,
3677    now_ms: u64,
3678) {
3679    record_health_action(snapshot, &spec.module_id, action.to_string(), now_ms);
3680    match action {
3681        HealthAction::Report => {
3682            info!(
3683                module_id = %spec.module_id,
3684                status = ?status,
3685                detail,
3686                "module reported non-ok health"
3687            );
3688        }
3689        HealthAction::Alert => {
3690            error!(
3691                module_id = %spec.module_id,
3692                status = ?status,
3693                detail,
3694                "module health alert"
3695            );
3696        }
3697        HealthAction::Restart => {
3698            if let Err(err) = health_restart_child(
3699                spec,
3700                runtime,
3701                registry,
3702                process_liveness,
3703                snapshot,
3704                child,
3705                status,
3706                detail,
3707                now_ms,
3708            )
3709            .await
3710            {
3711                error!(module_id = %spec.module_id, error = %err, "health-triggered restart failed");
3712            }
3713        }
3714    }
3715}
3716
3717#[allow(clippy::too_many_arguments)]
3718async fn health_restart_child(
3719    spec: &ModuleSpec,
3720    runtime: &SupervisorRuntimeConfig,
3721    registry: &Registry,
3722    process_liveness: &SupervisorProcessLiveness,
3723    snapshot: &SharedSnapshot,
3724    child: &mut Option<SupervisedChild>,
3725    status: SupervisorHealthStatus,
3726    detail: Option<&str>,
3727    now_ms: u64,
3728) -> Result<(), SuperviseError> {
3729    let (enabled, schedule) = {
3730        let mut state = lock_snapshot(snapshot)?;
3731        let enabled = state.enabled;
3732        let schedule = if enabled {
3733            state.next_crash_restart(&runtime.restart_policy, Instant::now())
3734        } else {
3735            None
3736        };
3737        (enabled, schedule)
3738    };
3739
3740    if !enabled {
3741        return Err(SuperviseError::Disabled {
3742            module_id: spec.module_id.clone(),
3743        });
3744    }
3745
3746    if schedule.is_none() {
3747        record_health_action(snapshot, &spec.module_id, "disabled".to_string(), now_ms);
3748        error!(
3749            module_id = %spec.module_id,
3750            status = ?status,
3751            detail,
3752            max_restarts = runtime.restart_policy.max_restarts,
3753            window_secs = runtime.restart_policy.window.as_secs(),
3754            "health restart budget exhausted; disabling module"
3755        );
3756        begin_forwarding_drain_if_configured(
3757            spec,
3758            runtime,
3759            registry,
3760            snapshot,
3761            Some(false),
3762            RouteCloseReason::Disable,
3763        )
3764        .await?;
3765        drain_optional_child(
3766            &spec.module_id,
3767            spec.protocol,
3768            registry,
3769            snapshot,
3770            &runtime.terminal_ring,
3771            &runtime.spawn_events,
3772            child,
3773            runtime.drain_timeout,
3774            ModuleState::Disabled,
3775            Some(false),
3776        )
3777        .await?;
3778        process_liveness.untrack_if_current(&spec.module_id, snapshot);
3779        return Ok(());
3780    }
3781
3782    let schedule = schedule.expect("a health restart must have a crash-restart schedule");
3783    let mut restart_count = 0;
3784    update_snapshot(snapshot, Some(&spec.module_id), |state| {
3785        restart_count = state.crash_restarts.len();
3786        state.state = ModuleState::Unresponsive;
3787        state.health.status = status;
3788        state.health.last_action = Some(HealthAction::Restart.to_string());
3789        state.health.last_action_ms = Some(now_ms);
3790    })?;
3791    warn!(
3792        module_id = %spec.module_id,
3793        status = ?status,
3794        detail,
3795        restart_count,
3796        restart_in_window = schedule.restart_in_window,
3797        delay_ms = schedule.delay.as_millis() as u64,
3798        "health-triggered module restart"
3799    );
3800
3801    begin_forwarding_drain_if_configured(
3802        spec,
3803        runtime,
3804        registry,
3805        snapshot,
3806        Some(true),
3807        RouteCloseReason::Restart,
3808    )
3809    .await?;
3810    drain_optional_child(
3811        &spec.module_id,
3812        spec.protocol,
3813        registry,
3814        snapshot,
3815        &runtime.terminal_ring,
3816        &runtime.spawn_events,
3817        child,
3818        runtime.drain_timeout,
3819        ModuleState::Restarting,
3820        Some(true),
3821    )
3822    .await?;
3823    sleep(schedule.delay).await;
3824    // The backoff may have outlasted the restart it was counting down to: an
3825    // operator disable or drain in between moves the snapshot out of
3826    // `Restarting`, and that stop must win over this respawn.
3827    if !respawn_still_pending(snapshot) {
3828        process_liveness.untrack_if_current(&spec.module_id, snapshot);
3829        return Ok(());
3830    }
3831    process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
3832    match spawn_and_mark_running(spec, runtime, snapshot) {
3833        Ok(next_child) => {
3834            *child = Some(next_child);
3835            Ok(())
3836        }
3837        Err(err) => {
3838            fail_snapshot(snapshot, Some(&spec.module_id), None);
3839            process_liveness.untrack_if_current(&spec.module_id, snapshot);
3840            *child = None;
3841            Err(err)
3842        }
3843    }
3844}
3845
3846fn record_health_action(snapshot: &SharedSnapshot, module_id: &str, action: String, now_ms: u64) {
3847    let _ = update_snapshot(snapshot, Some(module_id), |state| {
3848        state.health.last_action = Some(action);
3849        state.health.last_action_ms = Some(now_ms);
3850    });
3851}
3852
3853fn supervisor_health_status(status: HealthStatus) -> SupervisorHealthStatus {
3854    match status {
3855        HealthStatus::Ok => SupervisorHealthStatus::Ok,
3856        HealthStatus::Degraded => SupervisorHealthStatus::Degraded,
3857        HealthStatus::Failing => SupervisorHealthStatus::Failing,
3858    }
3859}
3860
3861/// Caps the metrics blob stored in the cached supervisor snapshot, which is
3862/// returned to every `supervisor.list` and `supervisor.health` caller.
3863///
3864/// This cap is deliberately NOT applied on the one-shot `supervisor.health_probe`
3865/// path: that request exists to return a module's complete metrics object, and
3866/// `ck health <module-id>` documents it as the way to see what the cached view
3867/// truncates. The asymmetry is the feature.
3868///
3869/// So a new caller must decide which side it is on rather than assume the cap is
3870/// universal. Reaching for it on a fresh-probe path would silently reintroduce
3871/// the truncation that path exists to avoid.
3872fn truncate_health_metrics(metrics: Option<Value>) -> Option<Value> {
3873    let metrics = metrics?;
3874    match serde_json::to_vec(&metrics) {
3875        Ok(encoded) if encoded.len() > MAX_HEALTH_METRICS_BYTES => Some(serde_json::json!({
3876            "truncated": true,
3877            "original_bytes": encoded.len(),
3878        })),
3879        Ok(_) | Err(_) => Some(metrics),
3880    }
3881}
3882
3883/// Spread health probes so a fleet-wide restart does not converge them.
3884///
3885/// The delay is derived from the module id and probe index rather than a random
3886/// source, so it is deterministic per module: a module keeps its own offset
3887/// across daemon restarts instead of re-rolling into a collision.
3888fn jittered_health_delay(module_id: &str, probe_index: u64, cadence: Duration) -> Duration {
3889    if cadence.is_zero() {
3890        return Duration::ZERO;
3891    }
3892    let cadence_ms = cadence.as_millis() as u64;
3893    // This early return is REDUNDANT, deliberately, and a mutation run will show
3894    // it surviving removal. Recording why here so the next person to notice does
3895    // not have to re-derive it:
3896    //
3897    // - It is unreachable in practice. `positive_millis` in daemon_config rejects
3898    //   a zero cadence and builds the Duration from whole milliseconds, so a
3899    //   sub-millisecond cadence cannot come from config.
3900    // - Even if reached it changes no answer. The `.max(1)` below makes the span
3901    //   1, and `hash % 1` is 0, so the fall-through returns `cadence` unchanged
3902    //   -- exactly what this returns.
3903    //
3904    // Kept as a guard against a future widening of the config parser (accepting
3905    // microseconds, say), which would make the sub-millisecond case reachable.
3906    // The `.max(1)` is the load-bearing half TODAY: remove it and the modulo
3907    // divides by zero. Remove this and nothing changes.
3908    if cadence_ms == 0 {
3909        return cadence;
3910    }
3911    // Note that this never returns less than one cadence, including for the FIRST
3912    // probe. So a freshly registered module reports health `unknown` for a full
3913    // cadence plus jitter -- 30-33s at the default -- no matter how quickly it is
3914    // ready to answer.
3915    //
3916    // That is a property of the supervisor's schedule, not of any module: an
3917    // operator watching a restart sees `unknown` and cannot tell it from a module
3918    // that is slow to warm. Measured on two unrelated modules, both flipping to
3919    // `ok` between 22s and 32s after restart.
3920    //
3921    // Left as-is because spreading the first probe is what keeps a fleet-wide
3922    // restart from firing fourteen simultaneous probes into a cold machine. The
3923    // alternative -- probe at t+0 and jitter only from the second onward -- trades
3924    // that thundering herd for a faster first reading.
3925    let jitter_span = (cadence_ms / 10).max(1);
3926    let hash = module_id.as_bytes().iter().fold(
3927        probe_index.wrapping_mul(0x9E37_79B9_7F4A_7C15),
3928        |acc, byte| {
3929            acc.wrapping_mul(1099511628211)
3930                .wrapping_add(u64::from(*byte))
3931        },
3932    );
3933    cadence + Duration::from_millis(hash % jitter_span)
3934}
3935
3936#[cfg(test)]
3937mod tests {
3938    use super::*;
3939
3940    #[test]
3941    fn readding_a_module_clears_its_rescan_removal_tombstone() {
3942        let handle = SupervisorHandle::new();
3943        let module_id = "readded-tombstone";
3944        handle.record_rescan_removal(module_id);
3945        assert!(handle.removal_tombstone_age_ms(module_id).is_some());
3946
3947        handle.apply_identity_configuration(&ModuleSpec {
3948            module_id: module_id.to_string(),
3949            program: PathBuf::from("/test/module"),
3950            args: Vec::new(),
3951            env: Vec::new(),
3952            reserved: false,
3953            reserved_prefixes: Vec::new(),
3954            protocol: ModuleProtocol::Subc,
3955            overlap: Default::default(),
3956        });
3957
3958        assert!(
3959            handle.removal_tombstone_age_ms(module_id).is_none(),
3960            "a re-added module must not retain a stale removal tombstone"
3961        );
3962    }
3963
3964    fn stale_process_snapshot(state: ModuleState, enabled: bool) -> SharedSnapshot {
3965        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(state, enabled)));
3966        update_snapshot(&snapshot, Some("stale-process-facts"), |snapshot| {
3967            snapshot.process_alive = true;
3968            snapshot.pid = Some(41);
3969            snapshot.spawned_at_ms = Some(42);
3970            snapshot.spawned_from = Some(PathBuf::from("/spawned/module"));
3971            snapshot.spawned_file_identity = Some(SpawnedFileIdentity {
3972                device: 43,
3973                inode: 44,
3974            });
3975        })
3976        .unwrap();
3977        snapshot
3978    }
3979
3980    fn assert_snapshot_process_facts_cleared(snapshot: &SharedSnapshot) {
3981        let snapshot = lock_snapshot(snapshot).unwrap();
3982        assert!(!snapshot.process_alive);
3983        assert_eq!(snapshot.pid, None);
3984        assert_eq!(snapshot.spawned_at_ms, None);
3985        assert_eq!(snapshot.spawned_from, None);
3986        assert_eq!(snapshot.spawned_file_identity, None);
3987    }
3988
3989    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3990    async fn failed_enable_spawn_clears_preexisting_current_process_facts() {
3991        let supervisor = Supervisor::default();
3992        let mut runtime = supervisor.runtime_config();
3993        runtime.test_seed_stale_facts_before_enable_spawn = true;
3994        let snapshot = stale_process_snapshot(ModuleState::Disabled, false);
3995        let mut child = None;
3996        let spec = ModuleSpec {
3997            module_id: "failed-enable-clears-facts".to_string(),
3998            program: PathBuf::from("/definitely/missing/failed-enable-module"),
3999            args: Vec::new(),
4000            env: Vec::new(),
4001            reserved: false,
4002            reserved_prefixes: Vec::new(),
4003            protocol: ModuleProtocol::Subc,
4004            overlap: Default::default(),
4005        };
4006
4007        let result = set_child_enabled(
4008            &spec,
4009            &runtime,
4010            &supervisor.registry,
4011            &supervisor.process_liveness,
4012            &snapshot,
4013            &mut child,
4014            true,
4015        )
4016        .await;
4017
4018        assert!(matches!(result, Err(SuperviseError::Spawn { .. })));
4019        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
4020        assert_snapshot_process_facts_cleared(&snapshot);
4021    }
4022
4023    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4024    async fn failed_reload_spawn_clears_current_process_facts() {
4025        let supervisor = Supervisor::default();
4026        let mut runtime = supervisor.runtime_config();
4027        runtime.restart_policy = RestartPolicy::new(0, Duration::ZERO);
4028        let snapshot = stale_process_snapshot(ModuleState::Running, true);
4029        let mut child = None;
4030        let spec = ModuleSpec {
4031            module_id: "failed-reload-clears-facts".to_string(),
4032            program: PathBuf::from("/unused/failed-reload-module"),
4033            args: Vec::new(),
4034            env: Vec::new(),
4035            reserved: false,
4036            reserved_prefixes: Vec::new(),
4037            protocol: ModuleProtocol::Subc,
4038            overlap: Default::default(),
4039        };
4040
4041        let result = handle_reload_spawn_failure(
4042            &spec,
4043            &runtime,
4044            &supervisor.process_liveness,
4045            &snapshot,
4046            &mut child,
4047            "forced reload spawn failure".to_string(),
4048        )
4049        .await;
4050
4051        assert!(matches!(result, Err(SuperviseError::ReloadFailed { .. })));
4052        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
4053        assert_snapshot_process_facts_cleared(&snapshot);
4054    }
4055
4056    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4057    async fn dropping_a_module_with_an_active_monitor_clears_current_process_facts() {
4058        let supervisor = Supervisor::default();
4059        let snapshot = stale_process_snapshot(ModuleState::Running, true);
4060        let module = supervisor.supervised_module(
4061            ModuleSpec {
4062                module_id: "drop-clears-facts".to_string(),
4063                program: PathBuf::from("/unused/drop-module"),
4064                args: Vec::new(),
4065                env: Vec::new(),
4066                reserved: false,
4067                reserved_prefixes: Vec::new(),
4068                protocol: ModuleProtocol::Subc,
4069                overlap: Default::default(),
4070            },
4071            supervisor.runtime_config(),
4072            Arc::clone(&snapshot),
4073            None,
4074        );
4075        assert!(!module
4076            .inner
4077            .monitor
4078            .lock()
4079            .unwrap()
4080            .as_ref()
4081            .unwrap()
4082            .is_finished());
4083
4084        drop(module);
4085
4086        assert_eq!(
4087            lock_snapshot(&snapshot).unwrap().state,
4088            ModuleState::Stopped
4089        );
4090        assert_snapshot_process_facts_cleared(&snapshot);
4091    }
4092
4093    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4094    async fn configuration_update_does_not_replace_captured_running_process_facts() {
4095        let supervisor = Supervisor::default();
4096        let snapshot = stale_process_snapshot(ModuleState::Running, true);
4097        let initial = ModuleSpec {
4098            module_id: "rescan-preserves-spawn-facts".to_string(),
4099            program: PathBuf::from("/spawned/module"),
4100            args: Vec::new(),
4101            env: Vec::new(),
4102            reserved: false,
4103            reserved_prefixes: Vec::new(),
4104            protocol: ModuleProtocol::Subc,
4105            overlap: Default::default(),
4106        };
4107        let module = supervisor.supervised_module(
4108            initial.clone(),
4109            supervisor.runtime_config(),
4110            snapshot,
4111            None,
4112        );
4113        let before = module.status().unwrap();
4114        let mut replacement = initial;
4115        replacement.program = PathBuf::from("/rescanned/replacement-module");
4116
4117        module
4118            .update_configuration(replacement, HealthConfig::default(), None)
4119            .await
4120            .unwrap();
4121
4122        let after = module.status().unwrap();
4123        assert_eq!(after.pid, before.pid);
4124        assert_eq!(after.spawned_at_ms, before.spawned_at_ms);
4125        assert_eq!(after.spawned_from, before.spawned_from);
4126        drop(module);
4127    }
4128}
4129
4130fn unix_ms_now() -> u64 {
4131    SystemTime::now()
4132        .duration_since(UNIX_EPOCH)
4133        .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
4134        .unwrap_or(0)
4135}
4136
4137async fn supervise_loop(
4138    mut spec: ModuleSpec,
4139    mut runtime: SupervisorRuntimeConfig,
4140    registry: Arc<Registry>,
4141    process_liveness: Arc<SupervisorProcessLiveness>,
4142    snapshot: SharedSnapshot,
4143    mut child: Option<SupervisedChild>,
4144    mut commands: mpsc::Receiver<SupervisorCommand>,
4145) {
4146    let mut health_probe = HealthProbeRuntime::default();
4147    // Deadline of the crash respawn whose backoff is currently elapsing. While
4148    // it is set the loop serves commands instead of sleeping inside the exit
4149    // arm, so a disable or drain lands immediately and cancels the respawn.
4150    let mut pending_respawn: Option<Instant> = None;
4151    // Commands a swap handed back to run next (see `swap::SwapEnd`). Served
4152    // before anything else so a stop that interrupted a swap runs at once.
4153    let mut requeued: VecDeque<SupervisorCommand> = VecDeque::new();
4154    loop {
4155        if let Some(command) = requeued.pop_front() {
4156            if !handle_supervisor_command(
4157                command,
4158                &mut spec,
4159                &mut runtime,
4160                &registry,
4161                &process_liveness,
4162                &snapshot,
4163                &mut child,
4164                &mut commands,
4165                &mut requeued,
4166            )
4167            .await
4168            {
4169                return;
4170            }
4171            if child.is_some() || !respawn_still_pending(&snapshot) {
4172                pending_respawn = None;
4173            }
4174            continue;
4175        }
4176        if child.is_some() {
4177            health_probe.refresh_registration(&spec, &runtime, &registry, &snapshot);
4178            let probe_sleep = sleep(health_probe.wake_after());
4179            tokio::pin!(probe_sleep);
4180            let active_child = child.as_mut().expect("child checked above");
4181            tokio::select! {
4182                wait_result = active_child.wait() => {
4183                    // Every arm below that gives up on the CHILD must keep the
4184                    // supervision task itself alive (child = None, loop
4185                    // continues into command-serving mode). Returning here
4186                    // closes the command channel, which makes the module
4187                    // permanently unrestartable in-band: a clean child exit
4188                    // of an enabled module once wedged the fleet this way
4189                    // ('supervisor command channel is closed') and required a
4190                    // full daemon restart to recover.
4191                    let exit_report = match wait_result {
4192                        Ok(status) => classify_reaped_child_exit(&snapshot, active_child, &status),
4193                        Err(err) => {
4194                            active_child.drain_stderr(&spec.module_id).await;
4195                            fail_snapshot(&snapshot, Some(&spec.module_id), None);
4196                            // Every other exit path (on_child_exit's Clean/Crash arms,
4197                            // the reload-registration-failure path) records a terminal
4198                            // before moving on. Without one here, a module whose wait()
4199                            // itself errored (e.g. already reaped) leaves no terminal
4200                            // record at all -- an empty ring reads as "nothing died".
4201                            record_wait_error_terminal(
4202                                &spec.module_id,
4203                                &runtime.terminal_ring,
4204                                &runtime.spawn_events,
4205                            );
4206                            untrack_if_registration_released(
4207                                &process_liveness,
4208                                &registry,
4209                                &spec.module_id,
4210                                &snapshot,
4211                            );
4212                            error!(module_id = %spec.module_id, error = %err, "failed to wait for supervised module");
4213                            child = None;
4214                            continue;
4215                        }
4216                    };
4217                    active_child.drain_stderr(&spec.module_id).await;
4218
4219                    match on_child_exit(
4220                        &spec,
4221                        runtime.restart_policy,
4222                        &registry,
4223                        &snapshot,
4224                        &runtime.terminal_ring,
4225                        &runtime.spawn_events,
4226                        exit_report,
4227                    ).await {
4228                        NextAction::Stop { registration_released } => {
4229                            if registration_released {
4230                                process_liveness.untrack_if_current(&spec.module_id, &snapshot);
4231                            }
4232                            child = None;
4233                        }
4234                        NextAction::Restart { schedule } => {
4235                            let delay = schedule.map_or(
4236                                runtime.restart_policy.delay_for_restart(0),
4237                                |schedule| schedule.delay,
4238                            );
4239                            if let Some(schedule) = schedule {
4240                                log_crash_respawn(&spec.module_id, schedule);
4241                            }
4242                            // The exited child is fully recorded at this point,
4243                            // so release it and count the backoff down in the
4244                            // command-serving branch below rather than sleeping
4245                            // here: commands cannot be received from inside this
4246                            // select arm, and an operator disable or drain that
4247                            // arrives during the backoff must cancel the pending
4248                            // respawn instead of waiting for it to spawn first.
4249                            child = None;
4250                            pending_respawn = Some(Instant::now() + delay);
4251                        }
4252                    }
4253                }
4254                command = commands.recv() => {
4255                    let Some(command) = command else {
4256                        return;
4257                    };
4258                    if !handle_supervisor_command(
4259                        command,
4260                        &mut spec,
4261                        &mut runtime,
4262                        &registry,
4263                        &process_liveness,
4264                        &snapshot,
4265                        &mut child,
4266                        &mut commands,
4267                        &mut requeued,
4268                    ).await {
4269                        return;
4270                    }
4271                }
4272                _ = &mut probe_sleep => {
4273                    if health_probe.due() {
4274                        run_health_probe_cycle(
4275                            &spec,
4276                            &runtime,
4277                            &registry,
4278                            &process_liveness,
4279                            &snapshot,
4280                            &mut child,
4281                        ).await;
4282                        if child.is_some() {
4283                            health_probe.schedule_next(&spec, runtime.health.cadence);
4284                        }
4285                    }
4286                }
4287            }
4288        } else if let Some(deadline) = pending_respawn {
4289            tokio::select! {
4290                _ = sleep_until(deadline) => {
4291                    pending_respawn = None;
4292                    // A command handled below while the backoff elapsed may
4293                    // have stopped the module; never respawn past an operator's
4294                    // disable or drain.
4295                    if !respawn_still_pending(&snapshot) {
4296                        continue;
4297                    }
4298                    if let Err(err) = wait_for_registration_release(
4299                        &registry,
4300                        &spec.module_id,
4301                        REGISTRY_RELEASE_TIMEOUT,
4302                    ).await {
4303                        fail_snapshot(&snapshot, Some(&spec.module_id), None);
4304                        error!(module_id = %spec.module_id, error = %err, "registration did not release before restart");
4305                        continue;
4306                    }
4307
4308                    match spawn_and_mark_running(&spec, &runtime, &snapshot) {
4309                        Ok(next_child) => {
4310                            child = Some(next_child);
4311                            debug!(module_id = %spec.module_id, "supervised module restarted after crash");
4312                        }
4313                        Err(err) => {
4314                            fail_snapshot(&snapshot, Some(&spec.module_id), None);
4315                            process_liveness.untrack_if_current(&spec.module_id, &snapshot);
4316                            error!(module_id = %spec.module_id, error = %err, "failed to restart supervised module");
4317                        }
4318                    }
4319                }
4320                command = commands.recv() => {
4321                    let Some(command) = command else {
4322                        return;
4323                    };
4324                    if !handle_supervisor_command(
4325                        command,
4326                        &mut spec,
4327                        &mut runtime,
4328                        &registry,
4329                        &process_liveness,
4330                        &snapshot,
4331                        &mut child,
4332                        &mut commands,
4333                        &mut requeued,
4334                    ).await {
4335                        return;
4336                    }
4337                    // Reconcile the pending respawn with what the command did:
4338                    // a restart or reload has already spawned a fresh child,
4339                    // while a disable or drain moved the snapshot out of the
4340                    // state the respawn was counting down from.
4341                    if child.is_some() || !respawn_still_pending(&snapshot) {
4342                        pending_respawn = None;
4343                    }
4344                }
4345            }
4346        } else {
4347            let Some(command) = commands.recv().await else {
4348                return;
4349            };
4350            if !handle_supervisor_command(
4351                command,
4352                &mut spec,
4353                &mut runtime,
4354                &registry,
4355                &process_liveness,
4356                &snapshot,
4357                &mut child,
4358                &mut commands,
4359                &mut requeued,
4360            )
4361            .await
4362            {
4363                return;
4364            }
4365        }
4366    }
4367}
4368
4369fn log_crash_respawn(module_id: &str, schedule: CrashRestartSchedule) {
4370    info!(
4371        module_id,
4372        restart_in_window = schedule.restart_in_window,
4373        delay_ms = schedule.delay.as_millis() as u64,
4374        "respawning after crash"
4375    );
4376}
4377
4378/// Whether the respawn a backoff was counting down to is still wanted. A
4379/// disable or drain handled while the backoff elapsed moves the snapshot out
4380/// of `Restarting`, and the operator's stop must win over the pending respawn,
4381/// so every sleep-then-spawn path re-validates against the live snapshot
4382/// instead of assuming the state it left behind still holds.
4383fn respawn_still_pending(snapshot: &SharedSnapshot) -> bool {
4384    matches!(
4385        lock_snapshot(snapshot),
4386        Ok(state) if state.enabled && state.state == ModuleState::Restarting
4387    )
4388}
4389
4390enum NextAction {
4391    Stop {
4392        registration_released: bool,
4393    },
4394    Restart {
4395        schedule: Option<CrashRestartSchedule>,
4396    },
4397}
4398
4399#[allow(clippy::too_many_arguments)]
4400async fn handle_supervisor_command(
4401    command: SupervisorCommand,
4402    spec: &mut ModuleSpec,
4403    runtime: &mut SupervisorRuntimeConfig,
4404    registry: &Registry,
4405    process_liveness: &SupervisorProcessLiveness,
4406    snapshot: &SharedSnapshot,
4407    child: &mut Option<SupervisedChild>,
4408    commands: &mut mpsc::Receiver<SupervisorCommand>,
4409    requeued: &mut VecDeque<SupervisorCommand>,
4410) -> bool {
4411    match command {
4412        SupervisorCommand::Drain { reply } => {
4413            let result = drain_optional_child(
4414                &spec.module_id,
4415                spec.protocol,
4416                registry,
4417                snapshot,
4418                &runtime.terminal_ring,
4419                &runtime.spawn_events,
4420                child,
4421                runtime.drain_timeout,
4422                ModuleState::Stopped,
4423                None,
4424            )
4425            .await;
4426            let registration_released = result.is_ok();
4427            let _ = reply.send(result);
4428            if registration_released {
4429                process_liveness.untrack_if_current(&spec.module_id, snapshot);
4430            }
4431            false
4432        }
4433        SupervisorCommand::Retire { reply } => {
4434            let result = async {
4435                begin_forwarding_drain_if_configured(
4436                    spec,
4437                    runtime,
4438                    registry,
4439                    snapshot,
4440                    None,
4441                    RouteCloseReason::Disable,
4442                )
4443                .await?;
4444                drain_optional_child(
4445                    &spec.module_id,
4446                    spec.protocol,
4447                    registry,
4448                    snapshot,
4449                    &runtime.terminal_ring,
4450                    &runtime.spawn_events,
4451                    child,
4452                    runtime.drain_timeout,
4453                    ModuleState::Stopped,
4454                    None,
4455                )
4456                .await
4457            }
4458            .await;
4459            let registration_released = result.is_ok();
4460            let _ = reply.send(result);
4461            if registration_released {
4462                process_liveness.untrack_if_current(&spec.module_id, snapshot);
4463            }
4464            false
4465        }
4466        SupervisorCommand::Restart {
4467            drain_timeout_ms,
4468            reply,
4469        } => {
4470            // ACK AT INITIATION, not completion. The blocking form deadlocked any
4471            // caller whose own request lane rides the module being restarted: the
4472            // caller's in-flight request keeps the drain from quiescing, the drain
4473            // keeps the restart from completing, and the completion keeps the reply
4474            // from releasing the caller — so the drain always timed out and cut the
4475            // initiator with a GOODBYE, even on a healthy module. Replying once the
4476            // restart is validated lets a self-lane caller settle, which is exactly
4477            // what makes the drain succeed. Completion is observable via
4478            // supervisor.list / module status; a post-ack failure lands the module
4479            // in a visible terminal state below rather than in a reply nobody can
4480            // receive.
4481            let validation = match lock_snapshot(snapshot) {
4482                Ok(state) if !state.enabled => Err(SuperviseError::Disabled {
4483                    module_id: spec.module_id.clone(),
4484                }),
4485                Ok(_) => Ok(()),
4486                Err(err) => Err(err),
4487            };
4488            let initiated = validation.is_ok();
4489            let _ = reply.send(validation);
4490            if initiated {
4491                // Precedence: this restart's operator override, else the module's
4492                // configured budget (already resolved into the runtime).
4493                let drain_timeout = drain_timeout_ms
4494                    .map(Duration::from_millis)
4495                    .unwrap_or(runtime.drain_timeout);
4496                if let Err(err) = restart_child(
4497                    spec,
4498                    runtime,
4499                    registry,
4500                    process_liveness,
4501                    snapshot,
4502                    child,
4503                    drain_timeout,
4504                )
4505                .await
4506                {
4507                    warn!(
4508                        module_id = %spec.module_id,
4509                        error = %err,
4510                        "operator restart failed after initiation ack; module state carries the outcome"
4511                    );
4512                    let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4513                        state.state = ModuleState::Failed;
4514                        clear_current_process_facts(state);
4515                    });
4516                }
4517            }
4518            true
4519        }
4520        SupervisorCommand::Reload { reply } => {
4521            let result =
4522                reload_child(spec, runtime, registry, process_liveness, snapshot, child).await;
4523            let _ = reply.send(result);
4524            true
4525        }
4526        SupervisorCommand::SetEnabled { enabled, reply } => {
4527            let result = set_child_enabled(
4528                spec,
4529                runtime,
4530                registry,
4531                process_liveness,
4532                snapshot,
4533                child,
4534                enabled,
4535            )
4536            .await;
4537            let _ = reply.send(result);
4538            true
4539        }
4540        SupervisorCommand::UpdateConfiguration {
4541            spec: next_spec,
4542            health,
4543            drain_timeout_ms,
4544            reply,
4545        } => {
4546            if let Some(handle) = &runtime.supervisor_handle {
4547                handle.apply_identity_configuration(&next_spec);
4548            }
4549            *spec = next_spec;
4550            runtime.health = health;
4551            runtime.drain_timeout = drain_timeout_ms
4552                .map(Duration::from_millis)
4553                .unwrap_or(runtime.default_drain_timeout);
4554            *runtime
4555                .effective_drain_timeout
4556                .lock()
4557                .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.drain_timeout;
4558            let _ = reply.send(());
4559            true
4560        }
4561        SupervisorCommand::Swap {
4562            ready_timeout,
4563            reply,
4564        } => {
4565            let end = swap::run_swap(
4566                spec,
4567                runtime,
4568                registry,
4569                process_liveness,
4570                snapshot,
4571                child,
4572                commands,
4573                ready_timeout.unwrap_or(DEFAULT_SWAP_READY_TIMEOUT),
4574                reply,
4575            )
4576            .await;
4577            requeued.extend(end.requeue);
4578            true
4579        }
4580    }
4581}
4582
4583async fn restart_child(
4584    spec: &ModuleSpec,
4585    runtime: &SupervisorRuntimeConfig,
4586    registry: &Registry,
4587    process_liveness: &SupervisorProcessLiveness,
4588    snapshot: &SharedSnapshot,
4589    child: &mut Option<SupervisedChild>,
4590    drain_timeout: Duration,
4591) -> Result<(), SuperviseError> {
4592    // Restart cycles a running module; it must not silently start a disabled one.
4593    if !lock_snapshot(snapshot)?.enabled {
4594        return Err(SuperviseError::Disabled {
4595            module_id: spec.module_id.clone(),
4596        });
4597    }
4598    begin_forwarding_drain_with_timeout(
4599        spec,
4600        runtime,
4601        registry,
4602        snapshot,
4603        None,
4604        RouteCloseReason::Restart,
4605        drain_timeout,
4606    )
4607    .await?;
4608
4609    if child.is_some() {
4610        drain_optional_child(
4611            &spec.module_id,
4612            spec.protocol,
4613            registry,
4614            snapshot,
4615            &runtime.terminal_ring,
4616            &runtime.spawn_events,
4617            child,
4618            drain_timeout,
4619            ModuleState::Restarting,
4620            Some(true),
4621        )
4622        .await?;
4623    } else {
4624        update_snapshot(snapshot, Some(&spec.module_id), |state| {
4625            state.enabled = true;
4626            state.state = ModuleState::Restarting;
4627            clear_current_process_facts(state);
4628        })?;
4629        wait_for_registration_release(registry, &spec.module_id, REGISTRY_RELEASE_TIMEOUT).await?;
4630    }
4631
4632    reset_restart_count(snapshot, &spec.module_id)?;
4633    sleep(runtime.restart_policy.backoff).await;
4634    // A disable or drain that landed during the backoff cancels this respawn:
4635    // the operator's stop must win over the restart the sleep counted down to.
4636    if !respawn_still_pending(snapshot) {
4637        process_liveness.untrack_if_current(&spec.module_id, snapshot);
4638        return Ok(());
4639    }
4640    process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
4641    // Mirror health_restart_child's spawn-failure handling: of the four
4642    // spawn-failure sites this was the only one that propagated with the
4643    // snapshot still reading `Restarting` -- neither running nor failed, and
4644    // unrevivable by `set_enabled(true)` (issue #34). `Failed` is the state the
4645    // operator can see and heal.
4646    match spawn_and_mark_running(spec, runtime, snapshot) {
4647        Ok(next_child) => {
4648            *child = Some(next_child);
4649            debug!(module_id = %spec.module_id, "supervised module restarted by operator request");
4650            Ok(())
4651        }
4652        Err(err) => {
4653            fail_snapshot(snapshot, Some(&spec.module_id), None);
4654            process_liveness.untrack_if_current(&spec.module_id, snapshot);
4655            *child = None;
4656            Err(err)
4657        }
4658    }
4659}
4660
4661async fn reload_child(
4662    spec: &ModuleSpec,
4663    runtime: &SupervisorRuntimeConfig,
4664    registry: &Registry,
4665    process_liveness: &SupervisorProcessLiveness,
4666    snapshot: &SharedSnapshot,
4667    child: &mut Option<SupervisedChild>,
4668) -> Result<(), SuperviseError> {
4669    // Reload cycles a running module; it must not silently start a disabled one.
4670    if !lock_snapshot(snapshot)?.enabled {
4671        return Err(SuperviseError::Disabled {
4672            module_id: spec.module_id.clone(),
4673        });
4674    }
4675    begin_forwarding_drain(
4676        spec,
4677        runtime,
4678        registry,
4679        snapshot,
4680        Some(true),
4681        RouteCloseReason::Reload,
4682    )
4683    .await?;
4684
4685    if child.is_some() {
4686        drain_optional_child(
4687            &spec.module_id,
4688            spec.protocol,
4689            registry,
4690            snapshot,
4691            &runtime.terminal_ring,
4692            &runtime.spawn_events,
4693            child,
4694            runtime.drain_timeout,
4695            ModuleState::Restarting,
4696            Some(true),
4697        )
4698        .await?;
4699    } else {
4700        update_snapshot(snapshot, Some(&spec.module_id), |state| {
4701            state.enabled = true;
4702            state.state = ModuleState::Restarting;
4703            clear_current_process_facts(state);
4704        })?;
4705        wait_for_registration_release(registry, &spec.module_id, REGISTRY_RELEASE_TIMEOUT).await?;
4706    }
4707
4708    reset_restart_count(snapshot, &spec.module_id)?;
4709    sleep(runtime.restart_policy.backoff).await;
4710    // A disable or drain that landed during the backoff cancels this respawn:
4711    // the operator's stop must win over the restart the sleep counted down to.
4712    if !respawn_still_pending(snapshot) {
4713        process_liveness.untrack_if_current(&spec.module_id, snapshot);
4714        return Ok(());
4715    }
4716    process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
4717    let next_child = match spawn_and_mark_running(spec, runtime, snapshot) {
4718        Ok(next_child) => next_child,
4719        Err(err) => {
4720            return handle_reload_spawn_failure(
4721                spec,
4722                runtime,
4723                process_liveness,
4724                snapshot,
4725                child,
4726                format!("new child failed to spawn: {err}"),
4727            )
4728            .await;
4729        }
4730    };
4731    *child = Some(next_child);
4732
4733    let wait_outcome = {
4734        let active_child = child.as_mut().expect("new reload child was just stored");
4735        wait_for_registration_after_reload(
4736            registry,
4737            &spec.module_id,
4738            snapshot,
4739            active_child,
4740            REGISTRY_RELEASE_TIMEOUT,
4741        )
4742        .await?
4743    };
4744
4745    match wait_outcome {
4746        RegistrationWaitOutcome::Registered => {
4747            debug!(module_id = %spec.module_id, "supervised module reloaded and registered");
4748            Ok(())
4749        }
4750        RegistrationWaitOutcome::Exited(exit_report) => {
4751            if let Some(active_child) = child.as_mut() {
4752                active_child.drain_stderr(&spec.module_id).await;
4753            }
4754            *child = None;
4755            handle_reload_child_registration_failure(
4756                spec,
4757                runtime,
4758                registry,
4759                process_liveness,
4760                snapshot,
4761                child,
4762                ReloadRegistrationFailure {
4763                    exit_report: registration_failure_exit_report(exit_report),
4764                    reason: "new child exited before registering".to_string(),
4765                },
4766            )
4767            .await
4768        }
4769        RegistrationWaitOutcome::TimedOut => {
4770            let mut timed_out_child = child
4771                .take()
4772                .expect("timed-out reload child is still running");
4773            timed_out_child
4774                .start_kill()
4775                .map_err(|source| SuperviseError::Kill {
4776                    module_id: spec.module_id.clone(),
4777                    source,
4778                })?;
4779            let status = timed_out_child
4780                .wait()
4781                .await
4782                .map_err(|source| SuperviseError::Wait {
4783                    module_id: spec.module_id.clone(),
4784                    source,
4785                })?;
4786            timed_out_child.drain_stderr(&spec.module_id).await;
4787            handle_reload_child_registration_failure(
4788                spec,
4789                runtime,
4790                registry,
4791                process_liveness,
4792                snapshot,
4793                child,
4794                ReloadRegistrationFailure {
4795                    exit_report: registration_failure_exit_report(classify_reaped_child_exit(
4796                        snapshot,
4797                        &timed_out_child,
4798                        &status,
4799                    )),
4800                    reason: format!(
4801                        "new child did not register within {:?}",
4802                        REGISTRY_RELEASE_TIMEOUT
4803                    ),
4804                },
4805            )
4806            .await
4807        }
4808    }
4809}
4810
4811async fn set_child_enabled(
4812    spec: &ModuleSpec,
4813    runtime: &SupervisorRuntimeConfig,
4814    registry: &Registry,
4815    process_liveness: &SupervisorProcessLiveness,
4816    snapshot: &SharedSnapshot,
4817    child: &mut Option<SupervisedChild>,
4818    enabled: bool,
4819) -> Result<bool, SuperviseError> {
4820    let (current_enabled, current_state) = {
4821        let state = lock_snapshot(snapshot)?;
4822        (state.enabled, state.state)
4823    };
4824    // `start` (enable on an already-enabled module) heals TERMINAL states instead
4825    // of no-op'ing: a module whose restart budget exhausted (Failed) or that exited
4826    // clean (Stopped) has no live process and no other in-band recovery — the
4827    // operator's start is the explicit recovery act and resets the budget. Without
4828    // this arm the only revival was subc-probe --supervisor-restart in a terminal,
4829    // which the 2026-07-14 aft outage proved is a trap when the failed module is
4830    // the one providing every agent's shell.
4831    let revive_terminal = enabled
4832        && current_enabled
4833        && child.is_none()
4834        && matches!(current_state, ModuleState::Failed | ModuleState::Stopped);
4835    if current_enabled == enabled && !revive_terminal {
4836        return Ok(false);
4837    }
4838
4839    if enabled {
4840        update_snapshot(snapshot, Some(&spec.module_id), |state| {
4841            state.enabled = true;
4842            state.state = ModuleState::Starting;
4843            clear_current_process_facts(state);
4844        })?;
4845        #[cfg(test)]
4846        if runtime.test_seed_stale_facts_before_enable_spawn {
4847            update_snapshot(snapshot, Some(&spec.module_id), |state| {
4848                state.process_alive = true;
4849                state.pid = Some(41);
4850                state.spawned_at_ms = Some(42);
4851                state.spawned_from = Some(PathBuf::from("/spawned/module"));
4852                state.spawned_file_identity = Some(SpawnedFileIdentity {
4853                    device: 43,
4854                    inode: 44,
4855                });
4856            })?;
4857        }
4858        wait_for_registration_release(registry, &spec.module_id, REGISTRY_RELEASE_TIMEOUT).await?;
4859        reset_restart_count(snapshot, &spec.module_id)?;
4860        process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
4861        let next_child = match spawn_and_mark_running(spec, runtime, snapshot) {
4862            Ok(next_child) => next_child,
4863            Err(err) => {
4864                if let Err(state_err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4865                    state.state = ModuleState::Failed;
4866                    clear_current_process_facts(state);
4867                }) {
4868                    error!(module_id = %spec.module_id, error = %state_err, "failed to record enable spawn failure");
4869                }
4870                process_liveness.untrack_if_current(&spec.module_id, snapshot);
4871                return Err(err);
4872            }
4873        };
4874        *child = Some(next_child);
4875        debug!(module_id = %spec.module_id, "supervised module enabled");
4876        Ok(true)
4877    } else {
4878        begin_forwarding_drain_if_configured(
4879            spec,
4880            runtime,
4881            registry,
4882            snapshot,
4883            Some(false),
4884            RouteCloseReason::Disable,
4885        )
4886        .await?;
4887        drain_optional_child(
4888            &spec.module_id,
4889            spec.protocol,
4890            registry,
4891            snapshot,
4892            &runtime.terminal_ring,
4893            &runtime.spawn_events,
4894            child,
4895            runtime.drain_timeout,
4896            ModuleState::Disabled,
4897            Some(false),
4898        )
4899        .await?;
4900        debug!(module_id = %spec.module_id, "supervised module disabled");
4901        Ok(true)
4902    }
4903}
4904
4905async fn on_child_exit(
4906    spec: &ModuleSpec,
4907    policy: RestartPolicy,
4908    registry: &Registry,
4909    snapshot: &SharedSnapshot,
4910    terminal_ring: &Arc<Mutex<TerminalRing>>,
4911    spawn_events: &SpawnEventFeed,
4912    exit_report: ExitReport,
4913) -> NextAction {
4914    match exit_report.kind {
4915        ExitKind::Clean => {
4916            info!(
4917                module_id = %spec.module_id,
4918                exit_code = ?exit_report.code,
4919                exit_signal = ?exit_report.signal,
4920                "supervised module exited cleanly"
4921            );
4922            if let Err(err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4923                state.state = ModuleState::Stopped;
4924                clear_current_process_facts(state);
4925                state.last_exit = Some(exit_report.clone());
4926            }) {
4927                error!(module_id = %spec.module_id, error = %err, "failed to record clean module exit");
4928            }
4929            record_terminal(
4930                &spec.module_id,
4931                terminal_ring,
4932                spawn_events,
4933                &exit_report,
4934                TerminalDisposition::Stopped,
4935            );
4936            let registration_released = match wait_for_registration_release(
4937                registry,
4938                &spec.module_id,
4939                REGISTRY_RELEASE_TIMEOUT,
4940            )
4941            .await
4942            {
4943                Ok(()) => true,
4944                Err(err) => {
4945                    warn!(module_id = %spec.module_id, error = %err, "registration still active after clean exit");
4946                    false
4947                }
4948            };
4949            NextAction::Stop {
4950                registration_released,
4951            }
4952        }
4953        ExitKind::Crash => {
4954            warn!(
4955                module_id = %spec.module_id,
4956                exit_code = ?exit_report.code,
4957                exit_signal = ?exit_report.signal,
4958                "supervised module exited abnormally (crash)"
4959            );
4960            let mut restart_schedule = None;
4961            let mut disposition = TerminalDisposition::Disabled;
4962            // Set only when the budget is what stopped the module, so the
4963            // terminal record says which limit was hit rather than leaving
4964            // `failed` to be read as "crashed once, badly".
4965            let mut disposition_detail = None;
4966            let now = Instant::now();
4967            if let Err(err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4968                clear_current_process_facts(state);
4969                state.last_exit = Some(exit_report.clone());
4970                if state.enabled {
4971                    if let Some(schedule) = state.next_crash_restart(&policy, now) {
4972                        state.state = ModuleState::Restarting;
4973                        restart_schedule = Some(schedule);
4974                        disposition = TerminalDisposition::Restarting;
4975                    } else {
4976                        state.state = ModuleState::Failed;
4977                        disposition = TerminalDisposition::Failed;
4978                        disposition_detail = Some(policy.budget_exhausted_detail());
4979                    }
4980                } else {
4981                    state.state = ModuleState::Disabled;
4982                    disposition = TerminalDisposition::Disabled;
4983                }
4984            }) {
4985                error!(module_id = %spec.module_id, error = %err, "failed to record crashed module exit");
4986                return NextAction::Stop {
4987                    registration_released: false,
4988                };
4989            }
4990            if disposition_detail.is_some() {
4991                // The window is in the message, not only in the fields: this line
4992                // is read in a scrollback where a bare `max_restarts=3` reads as a
4993                // lifetime cap and sends the operator looking for three crashes
4994                // that never happened together.
4995                error!(
4996                    module_id = %spec.module_id,
4997                    max_restarts = policy.max_restarts,
4998                    window_secs = policy.window.as_secs(),
4999                    "module stopped: {}",
5000                    policy.budget_exhausted_detail()
5001                );
5002            }
5003            record_terminal_with_detail(
5004                &spec.module_id,
5005                terminal_ring,
5006                spawn_events,
5007                &exit_report,
5008                disposition,
5009                disposition_detail,
5010            );
5011
5012            if let Some(schedule) = restart_schedule {
5013                NextAction::Restart {
5014                    schedule: Some(schedule),
5015                }
5016            } else {
5017                let registration_released = match wait_for_registration_release(
5018                    registry,
5019                    &spec.module_id,
5020                    REGISTRY_RELEASE_TIMEOUT,
5021                )
5022                .await
5023                {
5024                    Ok(()) => true,
5025                    Err(err) => {
5026                        warn!(module_id = %spec.module_id, error = %err, "registration still active after failed module");
5027                        false
5028                    }
5029                };
5030                NextAction::Stop {
5031                    registration_released,
5032                }
5033            }
5034        }
5035        ExitKind::DeliberateSeverance => {
5036            warn!(
5037                module_id = %spec.module_id,
5038                exit_code = ?exit_report.code,
5039                exit_signal = ?exit_report.signal,
5040                "supervised module exited after deliberate connection severance"
5041            );
5042            let mut should_restart = false;
5043            let mut disposition = TerminalDisposition::Disabled;
5044            if let Err(err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
5045                clear_current_process_facts(state);
5046                state.last_exit = Some(exit_report.clone());
5047                state.lifetime_restarts += 1;
5048                if state.enabled {
5049                    state.state = ModuleState::Restarting;
5050                    should_restart = true;
5051                    disposition = TerminalDisposition::Restarting;
5052                } else {
5053                    state.state = ModuleState::Disabled;
5054                }
5055            }) {
5056                error!(module_id = %spec.module_id, error = %err, "failed to record deliberately severed module exit");
5057                return NextAction::Stop {
5058                    registration_released: false,
5059                };
5060            }
5061            record_terminal(
5062                &spec.module_id,
5063                terminal_ring,
5064                spawn_events,
5065                &exit_report,
5066                disposition,
5067            );
5068
5069            if should_restart {
5070                NextAction::Restart { schedule: None }
5071            } else {
5072                let registration_released = match wait_for_registration_release(
5073                    registry,
5074                    &spec.module_id,
5075                    REGISTRY_RELEASE_TIMEOUT,
5076                )
5077                .await
5078                {
5079                    Ok(()) => true,
5080                    Err(err) => {
5081                        warn!(module_id = %spec.module_id, error = %err, "registration still active after deliberately severed module exit");
5082                        false
5083                    }
5084                };
5085                NextAction::Stop {
5086                    registration_released,
5087                }
5088            }
5089        }
5090    }
5091}
5092
5093fn record_wait_error_terminal(
5094    module_id: &str,
5095    terminal_ring: &Arc<Mutex<TerminalRing>>,
5096    spawn_events: &SpawnEventFeed,
5097) {
5098    record_terminal(
5099        module_id,
5100        terminal_ring,
5101        spawn_events,
5102        &wait_error_exit_report(),
5103        TerminalDisposition::Failed,
5104    );
5105}
5106
5107fn record_terminal(
5108    module_id: &str,
5109    terminal_ring: &Arc<Mutex<TerminalRing>>,
5110    spawn_events: &SpawnEventFeed,
5111    exit_report: &ExitReport,
5112    disposition: TerminalDisposition,
5113) {
5114    record_terminal_with_detail(
5115        module_id,
5116        terminal_ring,
5117        spawn_events,
5118        exit_report,
5119        disposition,
5120        None,
5121    );
5122}
5123
5124fn record_terminal_with_detail(
5125    module_id: &str,
5126    terminal_ring: &Arc<Mutex<TerminalRing>>,
5127    spawn_events: &SpawnEventFeed,
5128    exit_report: &ExitReport,
5129    disposition: TerminalDisposition,
5130    disposition_detail: Option<String>,
5131) {
5132    spawn_events.emit_exited(module_id, exit_report.code, exit_report.signal);
5133    let mut ring = terminal_ring
5134        .lock()
5135        .unwrap_or_else(|poisoned| poisoned.into_inner());
5136    let record = TerminalRecord {
5137        exit_code: exit_report.code,
5138        exit_signal: exit_report.signal,
5139        at_ms: exit_report.at_ms,
5140        disposition,
5141        exit_kind: exit_report.kind.into(),
5142        disposition_detail,
5143    };
5144    ring.append_journal(module_id, &record);
5145    ring.push(record);
5146}
5147
5148fn untrack_if_registration_released(
5149    process_liveness: &SupervisorProcessLiveness,
5150    registry: &Registry,
5151    module_id: &str,
5152    snapshot: &SharedSnapshot,
5153) {
5154    match registry.get_module(module_id) {
5155        Ok(None) => process_liveness.untrack_if_current(module_id, snapshot),
5156        Ok(Some(_)) => {}
5157        Err(err) => {
5158            warn!(module_id, error = %err, "could not determine whether supervisor liveness can be untracked");
5159        }
5160    }
5161}
5162
5163/// The child's environment plan: inherit the parent's, drop ambient `CK_LOG`,
5164/// then apply the module's configured entries minus daemon-private capture keys.
5165///
5166/// Separated from `spawn_child` only so it can be asserted without spawning a
5167/// process — a duplicate of this logic in a test would pass while the real one
5168/// drifted, which is the defect class this function exists to avoid.
5169/// The subc-wire half of a spawn: `--subc <connection file>` and the launch
5170/// nonce. A `protocol: "none"` module gets neither, because it cannot use
5171/// either and the argument would stop a stock binary from starting at all.
5172/// `SUBC_MODULE_ID` is set on every path since an unread variable is inert.
5173///
5174/// The plain-spawn form, kept for the tests that assert its plan; spawns go
5175/// through [`apply_wire_spawn_args_for_role`].
5176#[cfg(test)]
5177fn apply_wire_spawn_args(
5178    command: &mut Command,
5179    spec: &ModuleSpec,
5180    connection_file_path: Option<&std::path::Path>,
5181    handle: Option<&SupervisorHandle>,
5182) -> Result<(), SuperviseError> {
5183    apply_wire_spawn_args_for_role(
5184        command,
5185        spec,
5186        connection_file_path,
5187        handle,
5188        SpawnRole::Plain,
5189    )
5190}
5191
5192/// [`apply_wire_spawn_args`] for either slot.
5193///
5194/// A plain spawn's nonce replaces the module's recorded spawn (and reserved)
5195/// nonce, as every respawn always has. A swap candidate's nonce must leave
5196/// those alone, because the incumbent is still serving and its consumers still
5197/// attest with its nonce; it is recorded as the open swap's candidate token
5198/// instead, and the recording happens before the process exists so its HELLO
5199/// can never arrive ahead of it.
5200fn apply_wire_spawn_args_for_role(
5201    command: &mut Command,
5202    spec: &ModuleSpec,
5203    connection_file_path: Option<&std::path::Path>,
5204    handle: Option<&SupervisorHandle>,
5205    role: SpawnRole,
5206) -> Result<(), SuperviseError> {
5207    command.env(SUBC_MODULE_ID_ENV, &spec.module_id);
5208    if spec.protocol == ModuleProtocol::None {
5209        return Ok(());
5210    }
5211    if let Some(connection_file_path) = connection_file_path {
5212        command.arg(SUBC_ARG).arg(connection_file_path);
5213    }
5214
5215    // Every subc-wire spawn receives a fresh one-time launch nonce for consumer
5216    // route.open attestation. Reserved modules additionally use the same nonce
5217    // for HELLO id-squatting protection. A respawn rotates both records.
5218    let nonce = generate_launch_nonce()?;
5219    if let Some(handle) = handle {
5220        match role {
5221            SpawnRole::Plain => {
5222                handle.set_spawn_nonce(&spec.module_id, nonce.clone());
5223                if spec.reserved {
5224                    handle.set_reserved_nonce(&spec.module_id, nonce.clone());
5225                }
5226            }
5227            SpawnRole::SwapCandidate => handle.open_swap(&spec.module_id, nonce.clone()),
5228        }
5229    }
5230    command.env(SUBC_LAUNCH_NONCE_ENV, nonce);
5231    Ok(())
5232}
5233
5234fn apply_child_env(command: &mut Command, spec: &ModuleSpec) {
5235    command.env_remove(CK_LOG_ENV);
5236    // The spawn role is the supervisor's to set, and only on a swap candidate
5237    // (see `apply_spawn_role`). Removing it here, rather than just not setting
5238    // it, is what makes it absent on a plain spawn: the daemon's own
5239    // environment could carry it, and so could a spec built outside daemon
5240    // config (config refuses it as an `env` key). A module reading it on a
5241    // plain restart would pick the long swap budget and leave callers waiting.
5242    command.env_remove(SUBC_SPAWN_ROLE_ENV);
5243    for (key, value) in &spec.env {
5244        // cortexkit-log currently exposes retention only as a Rust struct, not
5245        // environment names. These values are daemon-private sink metadata and
5246        // must never become a public child-process contract by being inherited.
5247        if matches!(
5248            key.as_str(),
5249            CAPTURE_MAX_FILE_MB_ENV | CAPTURE_KEEP_ENV | CAPTURE_MAX_AGE_DAYS_ENV
5250        ) || key == SUBC_SPAWN_ROLE_ENV
5251        {
5252            continue;
5253        }
5254        command.env(key, value);
5255    }
5256}
5257
5258/// Which slot a spawn fills: the module's ordinary one, or the candidate slot
5259/// of a blue/green swap.
5260#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5261enum SpawnRole {
5262    Plain,
5263    SwapCandidate,
5264}
5265
5266/// Set the spawn role for a swap candidate. A plain spawn gets nothing here;
5267/// `apply_child_env` has already removed the variable for every spawn.
5268fn apply_spawn_role(command: &mut Command, role: SpawnRole) {
5269    if role == SpawnRole::SwapCandidate {
5270        command.env(SUBC_SPAWN_ROLE_ENV, SPAWN_ROLE_SWAP_CANDIDATE);
5271    }
5272}
5273
5274fn spawn_child(
5275    spec: &ModuleSpec,
5276    connection_file_path: Option<&std::path::Path>,
5277    handle: Option<&SupervisorHandle>,
5278    ring: &Arc<Mutex<StderrRing>>,
5279    capture_logs_dir: Option<&std::path::Path>,
5280    roster: &ChildRoster,
5281    #[cfg(target_os = "linux")] cgroup_placement: Option<&subc_cgroup::Placement>,
5282) -> Result<SupervisedChild, SuperviseError> {
5283    spawn_child_in_slot(
5284        spec,
5285        connection_file_path,
5286        handle,
5287        ring,
5288        capture_logs_dir,
5289        roster,
5290        #[cfg(target_os = "linux")]
5291        cgroup_placement,
5292        SpawnRole::Plain,
5293        false,
5294    )
5295}
5296
5297/// Spawn one process of `spec` into a slot.
5298///
5299/// `alternate_slot` picks the process's cgroup name (see `swap::cgroup_name`).
5300/// A swap candidate needs a different cgroup from the process it is replacing,
5301/// which is still alive: in the same cgroup the two would be one kill domain,
5302/// and killing a failed candidate could take the incumbent with it.
5303///
5304/// The stderr capture file is `<module_id>.stderr.log` for every process of
5305/// the module, whichever slot it is in, because that is the one file
5306/// `ck module logs` reads. During a swap's overlap both processes append to it;
5307/// the daemon writes whole lines, so the two interleave by line, which is also
5308/// the merged view an operator wants while a swap runs.
5309#[allow(clippy::too_many_arguments)]
5310fn spawn_child_in_slot(
5311    spec: &ModuleSpec,
5312    connection_file_path: Option<&std::path::Path>,
5313    handle: Option<&SupervisorHandle>,
5314    ring: &Arc<Mutex<StderrRing>>,
5315    capture_logs_dir: Option<&std::path::Path>,
5316    roster: &ChildRoster,
5317    #[cfg(target_os = "linux")] cgroup_placement: Option<&subc_cgroup::Placement>,
5318    role: SpawnRole,
5319    alternate_slot: bool,
5320) -> Result<SupervisedChild, SuperviseError> {
5321    if roster.is_closed() {
5322        return Err(SuperviseError::Spawn {
5323            program: spec.program.clone(),
5324            source: io::Error::other("the daemon is shutting down; not starting a new process"),
5325            cgroup_path: None,
5326        });
5327    }
5328    #[cfg(target_os = "linux")]
5329    let cgroup_name = swap::cgroup_name(&spec.module_id, alternate_slot);
5330    #[cfg(not(target_os = "linux"))]
5331    let _ = alternate_slot;
5332    let mut command = Command::new(&spec.program);
5333    command.args(&spec.args);
5334    // AMBIENT `CK_LOG` MUST NOT LEAK INTO AN OTHERWISE UNCONFIGURED MODULE — but
5335    // that is the whole of the intent, so remove that one key rather than the
5336    // environment.
5337    //
5338    // This was `env_clear()` from 0.17.41 until 0.18.3, which achieved the goal
5339    // and took the POSIX environment with it. Modules spawned that way had no
5340    // HOME, XDG_RUNTIME_DIR, TMPDIR or USER, and the consequences ran past
5341    // logging:
5342    //
5343    //   * `connection_file::discover` reads XDG_RUNTIME_DIR and HOME, so with
5344    //     both unset it fell back to the temp dir alone and `ck` could not find
5345    //     a daemon running on the same machine from inside any module's process
5346    //     tree — reporting a path the file has never lived at, which reads as
5347    //     "the daemon did not write its file".
5348    //   * `default_data_home()` with HOME and XDG_DATA_HOME both unset returns
5349    //     the RELATIVE `.local/share`, so a module deriving its own store path
5350    //     resolved it against its own CWD. That is the store-fragmentation
5351    //     defect the daemon already refuses in config (`parse_doc` rejects a
5352    //     relative `storage.data_home`) arriving by derivation instead.
5353    //   * anything a module spawns inherited it: git without ~/.gitconfig,
5354    //     cargo without CARGO_HOME, ssh, python user dirs — all degrading
5355    //     quietly rather than erroring.
5356    //
5357    // Reported by iceteaSA as #104 after deploying 0.18.2, where `ck daemon`
5358    // offered one candidate under /tmp while the file sat in /run/user/1000.
5359    //
5360    // A configured module is unaffected either way: `module_spec()` puts the
5361    // resolved CK_LOG into `spec.env`, which is applied below and therefore
5362    // wins over anything ambient.
5363    apply_child_env(&mut command, spec);
5364    apply_spawn_role(&mut command, role);
5365    apply_wire_spawn_args_for_role(&mut command, spec, connection_file_path, handle, role)?;
5366
5367    #[cfg(target_os = "linux")]
5368    let cgroup_path = cgroup_placement
5369        .map(|placement| placement.module_path(&cgroup_name))
5370        .transpose()
5371        .map_err(|source| SuperviseError::Cgroup {
5372            module_id: spec.module_id.clone(),
5373            source,
5374        })?;
5375    #[cfg(not(target_os = "linux"))]
5376    let cgroup_path: Option<PathBuf> = None;
5377    #[cfg(target_os = "linux")]
5378    if let Some(path) = &cgroup_path {
5379        if let Err(error) = apply_cgroup_placement(&mut command, spec, path) {
5380            if let Some(placement) = cgroup_placement {
5381                remove_module_cgroup(placement, &cgroup_name);
5382            }
5383            return Err(error);
5384        }
5385    }
5386
5387    let output_sink = if let Some(logs_dir) = capture_logs_dir {
5388        let path = logs_dir.join(format!("{}.stderr.log", spec.module_id));
5389        match ChildOutputSink::open(&path, capture_retention(spec)) {
5390            Ok(sink) => sink,
5391            Err(error) => {
5392                warn!(
5393                    module_id = %spec.module_id,
5394                    path = %path.display(),
5395                    error = %error,
5396                    "could not open child output capture file; forwarding to stderr"
5397                );
5398                ChildOutputSink::Stderr
5399            }
5400        }
5401    } else {
5402        ChildOutputSink::Stderr
5403    };
5404
5405    command.stdout(Stdio::piped());
5406    command.stderr(Stdio::piped());
5407    command.kill_on_drop(true);
5408    // EACH MODULE LEADS ITS OWN PROCESS GROUP (the child calls setpgid(0, 0)
5409    // before exec). In the daemon's group, a service manager that kills the
5410    // job's process group when the daemon exits (launchd's default) killed
5411    // every module at the same moment its control connection closed, so no
5412    // module ever ran its EOF teardown on a daemon stop. Outside that group a
5413    // module is reached only by the daemon: the EOF it sees when its
5414    // connection closes, and the bounded stop in `child_roster` for anything
5415    // still running after that. On Linux this composes with the cgroup
5416    // placement above: that is a pre_exec write to cgroup.procs, std performs
5417    // setpgid in the child before running pre_exec callbacks, and the two
5418    // change independent process attributes.
5419    //
5420    // stdin is /dev/null because a process outside the terminal's foreground
5421    // group is stopped (SIGTTIN) if it reads the terminal, which a daemon run
5422    // by hand would otherwise hand down. Under a service manager stdin is
5423    // already /dev/null.
5424    #[cfg(unix)]
5425    command.process_group(0);
5426    command.stdin(Stdio::null());
5427    let mut child = match command.spawn() {
5428        Ok(child) => child,
5429        Err(source) => {
5430            #[cfg(target_os = "linux")]
5431            if let Some(placement) = cgroup_placement {
5432                remove_module_cgroup(placement, &cgroup_name);
5433            }
5434            return Err(SuperviseError::Spawn {
5435                program: spec.program.clone(),
5436                source,
5437                cgroup_path,
5438            });
5439        }
5440    };
5441    let spawned_at_ms = unix_ms_now();
5442    let spawned_from = spec.program.clone();
5443    let spawned_file_identity = spawned_file_identity(&spawned_from);
5444    let pid = child.id().ok_or_else(|| SuperviseError::Spawn {
5445        program: spec.program.clone(),
5446        source: io::Error::other("spawned child exposed no live pid"),
5447        cgroup_path: cgroup_path.clone(),
5448    })?;
5449    let process_start_time = crate::provenance::process_start_time(pid);
5450    let process_identity = process_start_time.map(|start_time| ProcessIdentity { pid, start_time });
5451    let roster_guard = roster.admit(
5452        spec.module_id.clone(),
5453        pid,
5454        spec.protocol,
5455        process_start_time,
5456    );
5457
5458    let stdout_pump = match child.stdout.take() {
5459        Some(stdout) => Some(tokio::spawn(pump_stdout_to(stdout, output_sink.clone()))),
5460        None => {
5461            warn!(
5462                module_id = %spec.module_id,
5463                "spawned child exposed no stdout pipe; file capture will be incomplete"
5464            );
5465            None
5466        }
5467    };
5468    let stderr_pump = match child.stderr.take() {
5469        Some(stderr) => {
5470            ring.lock()
5471                .unwrap_or_else(|poisoned| poisoned.into_inner())
5472                .push_process_start();
5473            Some(tokio::spawn(pump_stderr_to(
5474                stderr,
5475                Arc::clone(ring),
5476                output_sink,
5477            )))
5478        }
5479        None => {
5480            // Spawning succeeded but the pipe did not materialise. Recording it as
5481            // uncaptured keeps the tail honest: the alternative is an empty tail
5482            // that reads as a module which printed nothing.
5483            ring.lock()
5484                .unwrap_or_else(|poisoned| poisoned.into_inner())
5485                .mark_not_captured("stderr pipe was not available on spawn");
5486            warn!(
5487                module_id = %spec.module_id,
5488                "spawned child exposed no stderr pipe; tail will be unavailable"
5489            );
5490            None
5491        }
5492    };
5493
5494    Ok(SupervisedChild {
5495        child,
5496        #[cfg(target_os = "linux")]
5497        module_id: cgroup_name,
5498        #[cfg(target_os = "linux")]
5499        cgroup_placement: cgroup_placement.cloned(),
5500        stdout_pump,
5501        stderr_pump,
5502        stderr_ring: Arc::clone(ring),
5503        spawned_at_ms,
5504        spawned_from,
5505        spawned_file_identity,
5506        process_start_time,
5507        process_identity,
5508        pid,
5509        roster_guard: Some(roster_guard),
5510    })
5511}
5512
5513#[cfg(target_os = "linux")]
5514fn remove_module_cgroup(placement: &subc_cgroup::Placement, module_id: &str) {
5515    match placement.remove_module(module_id) {
5516        Ok(()) => debug!(module_id, "removed module cgroup after process exit"),
5517        Err(error) => warn!(
5518            module_id,
5519            error = %error,
5520            "could not remove module cgroup after process exit; continuing teardown"
5521        ),
5522    }
5523}
5524
5525#[cfg(target_os = "linux")]
5526fn apply_cgroup_placement(
5527    command: &mut Command,
5528    spec: &ModuleSpec,
5529    path: &std::path::Path,
5530) -> Result<(), SuperviseError> {
5531    subc_cgroup::apply(command, path).map_err(|source| SuperviseError::Cgroup {
5532        module_id: spec.module_id.clone(),
5533        source,
5534    })
5535}
5536
5537fn capture_retention(spec: &ModuleSpec) -> Retention {
5538    let defaults = Retention::default();
5539    let value = |name: &str| {
5540        spec.env
5541            .iter()
5542            .rev()
5543            .find_map(|(key, value)| (key == name).then_some(value.as_str()))
5544    };
5545    Retention {
5546        max_file_mb: value(CAPTURE_MAX_FILE_MB_ENV)
5547            .and_then(|value| value.parse().ok())
5548            .unwrap_or(defaults.max_file_mb),
5549        keep: value(CAPTURE_KEEP_ENV)
5550            .and_then(|value| value.parse().ok())
5551            .unwrap_or(defaults.keep),
5552        max_age_days: value(CAPTURE_MAX_AGE_DAYS_ENV)
5553            .and_then(|value| value.parse().ok())
5554            .unwrap_or(defaults.max_age_days),
5555    }
5556}
5557
5558/// A fresh 256-bit CSPRNG launch nonce, lowercase hex. Used to bind a reserved
5559/// module's registration to the exact process the supervisor spawned.
5560fn generate_launch_nonce() -> Result<String, SuperviseError> {
5561    let mut bytes = [0u8; 32];
5562    getrandom::getrandom(&mut bytes).map_err(|source| SuperviseError::LaunchNonce {
5563        reason: source.to_string(),
5564    })?;
5565    let mut hex = String::with_capacity(64);
5566    for b in bytes {
5567        use std::fmt::Write;
5568        let _ = write!(hex, "{b:02x}");
5569    }
5570    Ok(hex)
5571}
5572
5573/// Constant-time byte comparison so a reserved-nonce mismatch leaks no timing
5574/// signal about how many leading bytes matched.
5575fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
5576    if a.len() != b.len() {
5577        return false;
5578    }
5579    let mut diff = 0u8;
5580    for (x, y) in a.iter().zip(b.iter()) {
5581        diff |= x ^ y;
5582    }
5583    diff == 0
5584}
5585
5586fn spawn_and_mark_running(
5587    spec: &ModuleSpec,
5588    runtime: &SupervisorRuntimeConfig,
5589    snapshot: &SharedSnapshot,
5590) -> Result<SupervisedChild, SuperviseError> {
5591    let child = spawn_child(
5592        spec,
5593        runtime.connection_file_path.as_deref(),
5594        runtime.supervisor_handle.as_ref(),
5595        &runtime.stderr_ring,
5596        runtime.capture_logs_dir.as_deref(),
5597        &runtime.child_roster,
5598        #[cfg(target_os = "linux")]
5599        runtime.cgroup_placement.as_ref(),
5600    )?;
5601    set_running(snapshot, &child, &spec.module_id, &runtime.spawn_events)?;
5602    Ok(child)
5603}
5604
5605enum RegistrationWaitOutcome {
5606    Registered,
5607    Exited(ExitReport),
5608    TimedOut,
5609}
5610
5611struct ReloadRegistrationFailure {
5612    exit_report: ExitReport,
5613    reason: String,
5614}
5615
5616#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5617enum BusyGaugeObservation {
5618    Quiescent,
5619    Busy,
5620    Omitted,
5621}
5622
5623fn busy_gauge_observation(metrics: Option<&Value>, gauges: &[String]) -> BusyGaugeObservation {
5624    let Some(metrics) = metrics.and_then(Value::as_object) else {
5625        return BusyGaugeObservation::Omitted;
5626    };
5627    let mut sum = 0u128;
5628    for gauge in gauges {
5629        let Some(value) = metrics.get(gauge) else {
5630            return BusyGaugeObservation::Omitted;
5631        };
5632        let Some(value) = value.as_u64() else {
5633            return BusyGaugeObservation::Busy;
5634        };
5635        sum = sum.saturating_add(u128::from(value));
5636    }
5637    if sum == 0 {
5638        BusyGaugeObservation::Quiescent
5639    } else {
5640        BusyGaugeObservation::Busy
5641    }
5642}
5643
5644fn declared_busy_gauges(
5645    registry: &Registry,
5646    module_id: &str,
5647) -> Result<Vec<String>, SuperviseError> {
5648    busy_gauges_of(
5649        registry
5650            .get_module(module_id)
5651            .map_err(SuperviseError::Registry)?,
5652    )
5653}
5654
5655/// [`declared_busy_gauges`] for the registration a connection holds, in any
5656/// slot: after cutover the incumbent is no longer the id's active
5657/// registration, and its own manifest is the one that names its gauges.
5658fn declared_busy_gauges_for_connection(
5659    registry: &Registry,
5660    connection_id: ConnectionId,
5661) -> Result<Vec<String>, SuperviseError> {
5662    busy_gauges_of(
5663        registry
5664            .get_module_by_connection(connection_id)
5665            .map_err(SuperviseError::Registry)?,
5666    )
5667}
5668
5669fn busy_gauges_of(
5670    registration: Option<crate::registry::ModuleRegistration>,
5671) -> Result<Vec<String>, SuperviseError> {
5672    let Some(registration) = registration else {
5673        return Ok(Vec::new());
5674    };
5675    let Some(self_signals) = registration.manifest.self_signals else {
5676        return Ok(Vec::new());
5677    };
5678
5679    let mut gauges = Vec::new();
5680    for declaration in self_signals {
5681        if declaration.kind != SelfSignalKind::Busy {
5682            continue;
5683        }
5684        match declaration.anchored_to {
5685            SignalAnchor::HealthGauges { gauges: declared } if !declared.is_empty() => {
5686                gauges.extend(declared)
5687            }
5688            _ => {
5689                // An invalid Busy anchor is fail-safe: the empty name cannot be
5690                // present in a conforming health report, so this drain stays busy.
5691                gauges.push(String::new());
5692            }
5693        }
5694    }
5695    Ok(gauges)
5696}
5697
5698/// Wait for `endpoint` to have nothing in flight and, when the module declares
5699/// busy gauges, for a health probe to report them quiet. The probe is addressed
5700/// by `scope`: a swap's superseded incumbent must be asked about its own
5701/// gauges, and by module id the probe would reach the promoted candidate.
5702async fn wait_for_forwarding_quiescence(
5703    forwarding: &ForwardingTable,
5704    module_id: &str,
5705    runtime: &SupervisorRuntimeConfig,
5706    endpoint: crate::ModuleEndpointId,
5707    deadline: Instant,
5708    busy_gauges: &[String],
5709    scope: DrainScope,
5710) -> Result<bool, SuperviseError> {
5711    let mut gauges_quiescent = busy_gauges.is_empty();
5712    let mut next_probe_at = Instant::now();
5713    let mut omission_counted = false;
5714
5715    loop {
5716        let now = Instant::now();
5717        if !busy_gauges.is_empty() && now >= next_probe_at && now < deadline {
5718            let report = match scope {
5719                DrainScope::Active => probe_module_health(module_id, runtime, Some(deadline)).await,
5720                DrainScope::Endpoint(endpoint) => {
5721                    probe_endpoint_health(endpoint, runtime, Some(deadline)).await
5722                }
5723            };
5724            gauges_quiescent = match report {
5725                Ok(report) => match busy_gauge_observation(report.metrics.as_ref(), busy_gauges) {
5726                    BusyGaugeObservation::Quiescent => true,
5727                    BusyGaugeObservation::Busy => false,
5728                    BusyGaugeObservation::Omitted => {
5729                        if !omission_counted {
5730                            forwarding
5731                                .counters()
5732                                .increment_drains_with_undeclared_gauge();
5733                            omission_counted = true;
5734                        }
5735                        false
5736                    }
5737                },
5738                Err(err) => {
5739                    warn!(
5740                        module_id,
5741                        error = %err,
5742                        "drain health.check did not produce declared busy gauges; treating module as busy"
5743                    );
5744                    false
5745                }
5746            };
5747            next_probe_at = Instant::now() + runtime.health.cadence.max(REGISTRY_RELEASE_POLL);
5748        }
5749
5750        let in_flight = forwarding
5751            .endpoint_in_flight_count(endpoint)
5752            .map_err(SuperviseError::Forwarding)?;
5753        if in_flight == 0 && gauges_quiescent {
5754            return Ok(true);
5755        }
5756
5757        let now = Instant::now();
5758        if now >= deadline {
5759            return Ok(false);
5760        }
5761        let mut wait = deadline
5762            .saturating_duration_since(now)
5763            .min(REGISTRY_RELEASE_POLL);
5764        if !busy_gauges.is_empty() {
5765            wait = wait.min(next_probe_at.saturating_duration_since(now));
5766        }
5767        sleep(wait).await;
5768    }
5769}
5770
5771/// The `route.closed` `drained` value implied by a quiescence-wait outcome.
5772///
5773/// `Ok` is always honest and passed straight through -- the wait actually measured
5774/// in-flight state. `Err` means the wait produced no measurement at all (the
5775/// forwarding table's lock was poisoned), so `false` is reported as the one honest
5776/// constant: the drain did not complete. Never recomputed from route state, never a
5777/// third "unknown" value -- the caller must still send a well-formed `route.closed`.
5778fn drained_after_quiescence_wait(wait_result: &Result<bool, SuperviseError>) -> bool {
5779    match wait_result {
5780        Ok(drained) => *drained,
5781        Err(_) => false,
5782    }
5783}
5784
5785fn send_route_goodbyes(forwarding: &ForwardingTable, released_routes: Vec<GoodbyeTarget>) {
5786    for released in released_routes {
5787        let frame = match Frame::build_with_version(
5788            released.negotiated_ver,
5789            FrameType::Goodbye,
5790            control_flags(),
5791            released.channel,
5792            released.epoch,
5793            0,
5794            Vec::new(),
5795        ) {
5796            Ok(frame) => frame,
5797            Err(err) => {
5798                warn!(
5799                    route_channel = released.channel,
5800                    error = %err,
5801                    "failed to build supervisor drain route GOODBYE frame"
5802                );
5803                continue;
5804            }
5805        };
5806        if let Err(err) = released.sink.try_send(frame) {
5807            if released.close_on_delivery_failure() {
5808                warn!(
5809                    target_connection_id = released.connection_id.get(),
5810                    route_channel = released.channel,
5811                    error = %err,
5812                    "supervisor drain route GOODBYE was not delivered to client; closing target connection"
5813                );
5814                let _ = forwarding.escalate_client_delivery_failure(
5815                    released.connection_id,
5816                    released.channel,
5817                    released.epoch,
5818                    CloseReason::new(
5819                        "route_goodbye_delivery_failed",
5820                        format!(
5821                            "failed to enqueue supervisor drain route GOODBYE for channel {}: {err}",
5822                            released.channel
5823                        ),
5824                    ),
5825                    crate::forwarding::UndeliveredFrame {
5826                        module_id: released.module_id.as_deref(),
5827                        sink: &released.sink,
5828                    },
5829                );
5830            } else {
5831                warn!(
5832                    target_connection_id = released.connection_id.get(),
5833                    route_channel = released.channel,
5834                    error = %err,
5835                    "supervisor drain route GOODBYE to module dropped under backpressure; not closing shared module connection"
5836                );
5837            }
5838        }
5839    }
5840}
5841
5842fn send_module_draining(
5843    module_id: &str,
5844    reason: RouteCloseReason,
5845    deadline_ms: u64,
5846    target: &ModuleDrainTarget,
5847) {
5848    let body = match serde_json::to_vec(&ModuleControlCommand::Draining {
5849        reason,
5850        deadline_ms,
5851    }) {
5852        Ok(body) => body,
5853        Err(err) => {
5854            warn!(
5855                module_id,
5856                error = %err,
5857                "failed to encode module draining command"
5858            );
5859            return;
5860        }
5861    };
5862    let frame = match Frame::build_with_version(
5863        target.negotiated_ver,
5864        FrameType::Push,
5865        control_flags(),
5866        0,
5867        0,
5868        0,
5869        body,
5870    ) {
5871        Ok(frame) => frame,
5872        Err(err) => {
5873            warn!(
5874                module_id,
5875                error = %err,
5876                "failed to build module draining command frame"
5877            );
5878            return;
5879        }
5880    };
5881    if let Err(err) = target.sink.try_send(frame) {
5882        warn!(
5883            module_id,
5884            target_connection_id = target.endpoint.connection_id.get(),
5885            error = %err,
5886            "module draining command was not delivered to peer"
5887        );
5888    }
5889}
5890
5891fn send_module_goodbye(module_id: &str, forwarding: &ForwardingTable, target: &ModuleDrainTarget) {
5892    let frame = match Frame::build_with_version(
5893        target.negotiated_ver,
5894        FrameType::Goodbye,
5895        control_flags(),
5896        0,
5897        0,
5898        0,
5899        Vec::new(),
5900    ) {
5901        Ok(frame) => frame,
5902        Err(err) => {
5903            warn!(
5904                module_id,
5905                error = %err,
5906                "failed to build supervisor drain module GOODBYE frame"
5907            );
5908            return;
5909        }
5910    };
5911    if let Err(err) = target.sink.try_send(frame) {
5912        warn!(
5913            module_id,
5914            target_connection_id = target.endpoint.connection_id.get(),
5915            error = %err,
5916            "supervisor drain module GOODBYE was not delivered to peer; closing module connection"
5917        );
5918        forwarding.request_connection_close(
5919            target.endpoint.connection_id,
5920            CloseReason::new(
5921                "module_goodbye_delivery_failed",
5922                format!("failed to enqueue supervisor drain module GOODBYE for module '{module_id}': {err}"),
5923            ),
5924        );
5925    }
5926}
5927
5928#[derive(Clone, Copy)]
5929struct ForwardingDrainContext<'a> {
5930    spec: &'a ModuleSpec,
5931    runtime: &'a SupervisorRuntimeConfig,
5932    registry: &'a Registry,
5933    scope: DrainScope,
5934}
5935
5936/// Which process a forwarding drain addresses.
5937#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5938enum DrainScope {
5939    /// Whatever endpoint is active for the module id: every plain stop,
5940    /// restart and reload. Also moves the module's state to `Draining`.
5941    Active,
5942    /// One specific endpoint: a swap's incumbent after cutover. Draining it by
5943    /// module id would resolve to the promoted candidate and leave neither
5944    /// process routable. The module's state is left alone, since the promoted
5945    /// candidate is what it describes and that process is running.
5946    Endpoint(crate::ModuleEndpointId),
5947}
5948
5949async fn begin_forwarding_drain(
5950    spec: &ModuleSpec,
5951    runtime: &SupervisorRuntimeConfig,
5952    registry: &Registry,
5953    snapshot: &SharedSnapshot,
5954    enabled: Option<bool>,
5955    reason: RouteCloseReason,
5956) -> Result<(), SuperviseError> {
5957    let Some(forwarding) = runtime.forwarding.as_ref() else {
5958        return Err(SuperviseError::ReloadUnavailable {
5959            module_id: spec.module_id.clone(),
5960            reason: "supervisor was not configured with a forwarding table".to_string(),
5961        });
5962    };
5963
5964    begin_forwarding_drain_with(
5965        forwarding,
5966        ForwardingDrainContext {
5967            spec,
5968            runtime,
5969            registry,
5970            scope: DrainScope::Active,
5971        },
5972        snapshot,
5973        enabled,
5974        reason,
5975        runtime.drain_timeout,
5976    )
5977    .await
5978}
5979
5980async fn begin_forwarding_drain_if_configured(
5981    spec: &ModuleSpec,
5982    runtime: &SupervisorRuntimeConfig,
5983    registry: &Registry,
5984    snapshot: &SharedSnapshot,
5985    enabled: Option<bool>,
5986    reason: RouteCloseReason,
5987) -> Result<(), SuperviseError> {
5988    begin_forwarding_drain_with_timeout(
5989        spec,
5990        runtime,
5991        registry,
5992        snapshot,
5993        enabled,
5994        reason,
5995        runtime.drain_timeout,
5996    )
5997    .await
5998}
5999
6000/// Like [`begin_forwarding_drain_if_configured`] but with an explicit drain
6001/// budget, for paths where the operator overrides the module's configured one
6002/// (`supervisor.restart{drain_timeout_ms}`).
6003async fn begin_forwarding_drain_with_timeout(
6004    spec: &ModuleSpec,
6005    runtime: &SupervisorRuntimeConfig,
6006    registry: &Registry,
6007    snapshot: &SharedSnapshot,
6008    enabled: Option<bool>,
6009    reason: RouteCloseReason,
6010    drain_timeout: Duration,
6011) -> Result<(), SuperviseError> {
6012    let Some(forwarding) = runtime.forwarding.as_ref() else {
6013        return Ok(());
6014    };
6015
6016    begin_forwarding_drain_with(
6017        forwarding,
6018        ForwardingDrainContext {
6019            spec,
6020            runtime,
6021            registry,
6022            scope: DrainScope::Active,
6023        },
6024        snapshot,
6025        enabled,
6026        reason,
6027        drain_timeout,
6028    )
6029    .await
6030}
6031
6032async fn begin_forwarding_drain_with(
6033    forwarding: &ForwardingTable,
6034    context: ForwardingDrainContext<'_>,
6035    snapshot: &SharedSnapshot,
6036    enabled: Option<bool>,
6037    reason: RouteCloseReason,
6038    drain_timeout: Duration,
6039) -> Result<(), SuperviseError> {
6040    let ForwardingDrainContext {
6041        spec,
6042        runtime,
6043        registry,
6044        scope,
6045    } = context;
6046    debug_assert_ne!(reason, RouteCloseReason::Crash);
6047    let terminal = matches!(reason, RouteCloseReason::Disable);
6048    let drain_started_at = Instant::now();
6049    let drain_deadline = drain_started_at + drain_timeout;
6050    let deadline_ms =
6051        unix_ms_now().saturating_add(u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX));
6052    let busy_gauges = match scope {
6053        DrainScope::Active => declared_busy_gauges(registry, &spec.module_id)?,
6054        DrainScope::Endpoint(endpoint) => {
6055            declared_busy_gauges_for_connection(registry, endpoint.connection_id)?
6056        }
6057    };
6058
6059    // Admission gate first: route.open/commit and route REQUEST admission are closed
6060    // before the first quiescence check, so the outstanding count can only fall.
6061    let drain_target = match scope {
6062        DrainScope::Active => forwarding.begin_module_drain(&spec.module_id, reason),
6063        DrainScope::Endpoint(endpoint) => forwarding.begin_endpoint_drain(endpoint, reason),
6064    }
6065    .map_err(SuperviseError::Forwarding)?;
6066    if scope == DrainScope::Active {
6067        update_snapshot(snapshot, Some(&spec.module_id), |state| {
6068            state.state = ModuleState::Draining;
6069            if let Some(enabled) = enabled {
6070                state.enabled = enabled;
6071            }
6072        })?;
6073    }
6074
6075    if let Some(target) = drain_target.as_ref() {
6076        send_module_draining(&spec.module_id, reason, deadline_ms, target);
6077        let routes = forwarding
6078            .endpoint_routes(target.endpoint)
6079            .map_err(SuperviseError::Forwarding)?;
6080        let routes_notified = routes.len();
6081        crate::control::send_route_control_pushes(
6082            forwarding,
6083            routes.clone(),
6084            ClientControlPush::RouteClosing {
6085                module_id: spec.module_id.clone(),
6086                reason,
6087            },
6088        );
6089        send_route_goodbyes(forwarding, target.abandoned_bindings.clone());
6090
6091        // `route.closing` was just sent above: from here on every return path,
6092        // including an early one, MUST send `route.closed` before propagating
6093        // anything else. A client holds `closing` as a promise that a verdict is
6094        // coming; leaving early without `closed` strands it waiting forever, since
6095        // `closing` carries no timeout of its own.
6096        let wait_result = wait_for_forwarding_quiescence(
6097            forwarding,
6098            &spec.module_id,
6099            runtime,
6100            target.endpoint,
6101            drain_deadline,
6102            &busy_gauges,
6103            scope,
6104        )
6105        .await;
6106        let drained = drained_after_quiescence_wait(&wait_result);
6107        if let Err(err) = &wait_result {
6108            error!(
6109                module_id = %spec.module_id,
6110                ?reason,
6111                error = %err,
6112                "forwarding quiescence wait failed after route.closing; forcing route.closed(drained: false) so the client is not left waiting on an unfulfilled promise"
6113            );
6114        } else if !drained {
6115            // Name what the drain waited on. Without it the line says only that
6116            // something did not settle, and "one wedged call" and "every
6117            // session's held stream" read the same; the first is a module bug,
6118            // the second is a module that should end its streams on
6119            // module.draining. Read before teardown releases the routes.
6120            let holdouts = forwarding
6121                .endpoint_drain_holdouts(target.endpoint)
6122                .unwrap_or_default();
6123            warn!(
6124                module_id = %spec.module_id,
6125                waited = ?drain_timeout,
6126                ?reason,
6127                held_requests = holdouts.requests,
6128                held_routes = holdouts.routes,
6129                total_routes = holdouts.total_routes,
6130                top_connections = ?holdouts.top_connections,
6131                // `module_channel:corr`, so the module can find each held request
6132                // in its own log; capped, so `held_requests` is the full count.
6133                held = %holdouts
6134                    .held
6135                    .iter()
6136                    .map(|(channel, corr)| format!("{channel}:{corr}"))
6137                    .collect::<Vec<_>>()
6138                    .join(","),
6139                "route drain timed out before request quiescence; forcing teardown"
6140            );
6141        }
6142        crate::control::send_route_control_pushes(
6143            forwarding,
6144            routes,
6145            ClientControlPush::RouteClosed {
6146                module_id: spec.module_id.clone(),
6147                reason,
6148                drained,
6149                abandoned: target.abandoned_bindings.len() as u32,
6150                excluded_subscriptions: target.excluded_subscriptions,
6151                terminal: Some(terminal),
6152            },
6153        );
6154        wait_result?;
6155
6156        // `route.closed` has now been sent unconditionally above. From here the
6157        // remaining steps are cleanup (route + module GOODBYE) rather than a
6158        // promise the client is waiting on, but a lock-poisoned
6159        // `release_module_endpoint_routes` would otherwise skip the module
6160        // GOODBYE silently too -- send it before propagating the error.
6161        let released_routes = match forwarding.release_module_endpoint_routes(target.endpoint) {
6162            Ok(routes) => routes,
6163            Err(err) => {
6164                warn!(
6165                    module_id = %spec.module_id,
6166                    ?reason,
6167                    error = %err,
6168                    "failed to release module endpoint routes after route.closed; module GOODBYE will still be sent"
6169                );
6170                send_module_goodbye(&spec.module_id, forwarding, target);
6171                return Err(SuperviseError::Forwarding(err));
6172            }
6173        };
6174        let route_goodbye_count = released_routes.len();
6175        send_route_goodbyes(forwarding, released_routes);
6176        send_module_goodbye(&spec.module_id, forwarding, target);
6177
6178        // The drain's happy path was previously silent: every emission above is
6179        // best-effort with only its failure arm logged, so "were consumers told"
6180        // was unprovable from the daemon log (surfaced by a 30-minute consumer
6181        // hang where the open question was exactly whether teardown notice went
6182        // out). One summary line makes that class decidable in one grep.
6183        info!(
6184            module_id = %spec.module_id,
6185            ?reason,
6186            routes_notified,
6187            route_goodbyes = route_goodbye_count,
6188            abandoned_reservations = target.abandoned_bindings.len(),
6189            excluded_subscriptions = target.excluded_subscriptions,
6190            drained,
6191            "module drain complete; consumers notified via route.closing/route.closed pushes and per-route GOODBYE frames"
6192        );
6193    }
6194
6195    Ok(())
6196}
6197
6198/// Wait for the freshly spawned child to take the ACTIVE slot for `module_id`,
6199/// the only slot a plain (non-swap) spawn can register into.
6200async fn wait_for_registration_after_reload(
6201    registry: &Registry,
6202    module_id: &str,
6203    snapshot: &SharedSnapshot,
6204    child: &mut SupervisedChild,
6205    wait: Duration,
6206) -> Result<RegistrationWaitOutcome, SuperviseError> {
6207    wait_for_slot_registration(
6208        registry,
6209        crate::registry::RegistrationSlot::Active(module_id),
6210        module_id,
6211        snapshot,
6212        child,
6213        wait,
6214    )
6215    .await
6216}
6217
6218/// Wait for `child` to register into `slot`, or to exit, or for `wait` to pass.
6219///
6220/// Keyed on the slot rather than the bare module id because during a swap the
6221/// id's active slot is already held by the incumbent: an id-keyed wait would
6222/// report the incumbent's registration as the candidate's and a candidate that
6223/// never registers would look registered. A swap candidate waits on
6224/// `crate::registry::RegistrationSlot::Candidate`.
6225async fn wait_for_slot_registration(
6226    registry: &Registry,
6227    slot: crate::registry::RegistrationSlot<'_>,
6228    module_id: &str,
6229    snapshot: &SharedSnapshot,
6230    child: &mut SupervisedChild,
6231    wait: Duration,
6232) -> Result<RegistrationWaitOutcome, SuperviseError> {
6233    let deadline = Instant::now() + wait;
6234    loop {
6235        if registry
6236            .registration(slot)
6237            .map_err(SuperviseError::Registry)?
6238            .is_some()
6239        {
6240            return Ok(RegistrationWaitOutcome::Registered);
6241        }
6242
6243        let now = Instant::now();
6244        if now >= deadline {
6245            return Ok(RegistrationWaitOutcome::TimedOut);
6246        }
6247        let remaining = deadline.saturating_duration_since(now);
6248        let poll = remaining.min(REGISTRY_RELEASE_POLL);
6249
6250        tokio::select! {
6251            wait_result = child.wait() => {
6252                let status = wait_result.map_err(|source| SuperviseError::Wait {
6253                    module_id: module_id.to_string(),
6254                    source,
6255                })?;
6256                return Ok(RegistrationWaitOutcome::Exited(classify_reaped_child_exit(
6257                    snapshot,
6258                    child,
6259                    &status,
6260                )));
6261            }
6262            _ = sleep(poll) => {}
6263        }
6264    }
6265}
6266
6267fn registration_failure_exit_report(mut exit_report: ExitReport) -> ExitReport {
6268    // A replacement process that exits before HELLO did not provide service, even
6269    // if it used status 0. Count it against the restart cap as a new-binary failure.
6270    if exit_report.kind != ExitKind::DeliberateSeverance {
6271        exit_report.kind = ExitKind::Crash;
6272    }
6273    exit_report
6274}
6275
6276async fn handle_reload_child_registration_failure(
6277    spec: &ModuleSpec,
6278    runtime: &SupervisorRuntimeConfig,
6279    registry: &Registry,
6280    process_liveness: &SupervisorProcessLiveness,
6281    snapshot: &SharedSnapshot,
6282    child: &mut Option<SupervisedChild>,
6283    failure: ReloadRegistrationFailure,
6284) -> Result<(), SuperviseError> {
6285    let ReloadRegistrationFailure {
6286        exit_report,
6287        reason,
6288    } = failure;
6289    match on_child_exit(
6290        spec,
6291        runtime.restart_policy,
6292        registry,
6293        snapshot,
6294        &runtime.terminal_ring,
6295        &runtime.spawn_events,
6296        exit_report,
6297    )
6298    .await
6299    {
6300        NextAction::Stop {
6301            registration_released,
6302        } => {
6303            if registration_released {
6304                process_liveness.untrack_if_current(&spec.module_id, snapshot);
6305            }
6306        }
6307        NextAction::Restart { schedule } => {
6308            let delay = schedule.map_or(runtime.restart_policy.delay_for_restart(0), |schedule| {
6309                schedule.delay
6310            });
6311            if let Some(schedule) = schedule {
6312                log_crash_respawn(&spec.module_id, schedule);
6313            }
6314            sleep(delay).await;
6315            // A disable or drain that landed during the backoff cancels this
6316            // policy retry: the operator's stop must win over the respawn the
6317            // sleep counted down to.
6318            if respawn_still_pending(snapshot) {
6319                if let Err(err) = wait_for_registration_release(
6320                    registry,
6321                    &spec.module_id,
6322                    REGISTRY_RELEASE_TIMEOUT,
6323                )
6324                .await
6325                {
6326                    fail_snapshot(snapshot, Some(&spec.module_id), None);
6327                    process_liveness.untrack_if_current(&spec.module_id, snapshot);
6328                    return Err(SuperviseError::ReloadFailed {
6329                        module_id: spec.module_id.clone(),
6330                        reason: format!(
6331                            "{reason}; registration did not release before policy retry: {err}"
6332                        ),
6333                    });
6334                }
6335                process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
6336                match spawn_and_mark_running(spec, runtime, snapshot) {
6337                    Ok(next_child) => {
6338                        *child = Some(next_child);
6339                    }
6340                    Err(err) => {
6341                        fail_snapshot(snapshot, Some(&spec.module_id), None);
6342                        process_liveness.untrack_if_current(&spec.module_id, snapshot);
6343                        return Err(SuperviseError::ReloadFailed {
6344                            module_id: spec.module_id.clone(),
6345                            reason: format!("{reason}; policy retry spawn failed: {err}"),
6346                        });
6347                    }
6348                }
6349            }
6350        }
6351    }
6352
6353    Err(SuperviseError::ReloadFailed {
6354        module_id: spec.module_id.clone(),
6355        reason,
6356    })
6357}
6358
6359async fn handle_reload_spawn_failure(
6360    spec: &ModuleSpec,
6361    runtime: &SupervisorRuntimeConfig,
6362    process_liveness: &SupervisorProcessLiveness,
6363    snapshot: &SharedSnapshot,
6364    child: &mut Option<SupervisedChild>,
6365    reason: String,
6366) -> Result<(), SuperviseError> {
6367    let mut should_retry = false;
6368    let now = Instant::now();
6369    update_snapshot(snapshot, Some(&spec.module_id), |state| {
6370        clear_current_process_facts(state);
6371        if daemon_will_restart(state, &runtime.restart_policy, now) {
6372            state.record_crash_restart(&runtime.restart_policy, now);
6373            state.state = ModuleState::Restarting;
6374            should_retry = true;
6375        } else if state.enabled {
6376            state.state = ModuleState::Failed;
6377        } else {
6378            state.state = ModuleState::Disabled;
6379        }
6380    })?;
6381
6382    if should_retry {
6383        sleep(runtime.restart_policy.backoff).await;
6384        // A disable or drain that landed during the backoff cancels this
6385        // policy retry: the operator's stop must win over the respawn the
6386        // sleep counted down to.
6387        if respawn_still_pending(snapshot) {
6388            process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
6389            match spawn_and_mark_running(spec, runtime, snapshot) {
6390                Ok(next_child) => {
6391                    *child = Some(next_child);
6392                }
6393                Err(err) => {
6394                    fail_snapshot(snapshot, Some(&spec.module_id), None);
6395                    process_liveness.untrack_if_current(&spec.module_id, snapshot);
6396                    return Err(SuperviseError::ReloadFailed {
6397                        module_id: spec.module_id.clone(),
6398                        reason: format!("{reason}; policy retry spawn failed: {err}"),
6399                    });
6400                }
6401            }
6402        }
6403    } else {
6404        process_liveness.untrack_if_current(&spec.module_id, snapshot);
6405    }
6406
6407    Err(SuperviseError::ReloadFailed {
6408        module_id: spec.module_id.clone(),
6409        reason,
6410    })
6411}
6412
6413fn control_flags() -> Flags {
6414    Flags::new(false, Priority::Passive, false)
6415}
6416
6417#[allow(clippy::too_many_arguments)]
6418async fn drain_optional_child(
6419    module_id: &str,
6420    protocol: ModuleProtocol,
6421    registry: &Registry,
6422    snapshot: &SharedSnapshot,
6423    terminal_ring: &Arc<Mutex<TerminalRing>>,
6424    spawn_events: &SpawnEventFeed,
6425    child: &mut Option<SupervisedChild>,
6426    drain_timeout: Duration,
6427    final_state: ModuleState,
6428    enabled: Option<bool>,
6429) -> Result<(), SuperviseError> {
6430    if let Some(child) = child.take() {
6431        drain_child_to_state(
6432            module_id,
6433            protocol,
6434            registry,
6435            snapshot,
6436            terminal_ring,
6437            spawn_events,
6438            child,
6439            drain_timeout,
6440            final_state,
6441            enabled,
6442        )
6443        .await
6444    } else {
6445        update_snapshot(snapshot, Some(module_id), |state| {
6446            state.state = final_state;
6447            if let Some(enabled) = enabled {
6448                state.enabled = enabled;
6449            }
6450            clear_current_process_facts(state);
6451        })?;
6452        wait_for_registration_release(registry, module_id, REGISTRY_RELEASE_TIMEOUT).await
6453    }
6454}
6455
6456#[allow(clippy::too_many_arguments)]
6457async fn drain_child_to_state(
6458    module_id: &str,
6459    protocol: ModuleProtocol,
6460    registry: &Registry,
6461    snapshot: &SharedSnapshot,
6462    terminal_ring: &Arc<Mutex<TerminalRing>>,
6463    spawn_events: &SpawnEventFeed,
6464    mut child: SupervisedChild,
6465    drain_timeout: Duration,
6466    final_state: ModuleState,
6467    enabled: Option<bool>,
6468) -> Result<(), SuperviseError> {
6469    update_snapshot(snapshot, Some(module_id), |state| {
6470        state.state = ModuleState::Draining;
6471        if let Some(enabled) = enabled {
6472            state.enabled = enabled;
6473        }
6474    })?;
6475
6476    // The wait below is the same budget for both protocols; what differs is
6477    // whether anything has ASKED the child to stop before it starts. A subc
6478    // module was told over its own connection before reaching here. A module
6479    // that speaks no subc wire was told nothing, so without this the budget is
6480    // only a delay in front of SIGKILL.
6481    if protocol == ModuleProtocol::None {
6482        request_graceful_stop(module_id, &child);
6483    }
6484
6485    let exit_report = match timeout(drain_timeout, child.wait()).await {
6486        Ok(Ok(status)) => classify_reaped_child_exit(snapshot, &child, &status),
6487        Ok(Err(source)) => {
6488            fail_snapshot(snapshot, Some(module_id), None);
6489            return Err(SuperviseError::Wait {
6490                module_id: module_id.to_string(),
6491                source,
6492            });
6493        }
6494        Err(_) => {
6495            // Mirror the sibling arm above: state is already `Draining`, and an
6496            // error propagated from here would strand it there -- a state
6497            // `set_enabled(true)` cannot heal (`revive_terminal` matches only
6498            // `Failed | Stopped`), leaving an operator Restart as the only exit.
6499            // `Failed` before `?` keeps the module operator-visible and
6500            // revivable. Trigger is an ESRCH race (process exits between the
6501            // drain timeout firing and the kill) or a post-kill wait failure
6502            // (issue #34).
6503            child.start_kill().map_err(|source| {
6504                fail_snapshot(snapshot, Some(module_id), None);
6505                SuperviseError::Kill {
6506                    module_id: module_id.to_string(),
6507                    source,
6508                }
6509            })?;
6510            let status = child.wait().await.map_err(|source| {
6511                fail_snapshot(snapshot, Some(module_id), None);
6512                SuperviseError::Wait {
6513                    module_id: module_id.to_string(),
6514                    source,
6515                }
6516            })?;
6517            classify_reaped_child_exit(snapshot, &child, &status)
6518        }
6519    };
6520
6521    update_snapshot(snapshot, Some(module_id), |state| {
6522        state.state = final_state;
6523        if let Some(enabled) = enabled {
6524            state.enabled = enabled;
6525        }
6526        clear_current_process_facts(state);
6527        state.last_exit = Some(exit_report.clone());
6528        if exit_report.kind == ExitKind::DeliberateSeverance {
6529            state.lifetime_restarts += 1;
6530        }
6531    })?;
6532    record_terminal(
6533        module_id,
6534        terminal_ring,
6535        spawn_events,
6536        &exit_report,
6537        terminal_disposition(final_state),
6538    );
6539    child.drain_stderr(module_id).await;
6540
6541    wait_for_registration_release(registry, module_id, REGISTRY_RELEASE_TIMEOUT).await
6542}
6543
6544/// Ask a `protocol: "none"` child to stop, the only way such a child can be
6545/// asked.
6546///
6547/// A subc module is asked over its own connection: the drain sends
6548/// `route.closing`/`route.closed` to its consumers, a GOODBYE per route, then a
6549/// module GOODBYE, and the module stops itself. A module that speaks no subc
6550/// wire receives none of that, so before this the drain budget was pure delay in
6551/// front of a SIGKILL -- and for a process with a store to flush (JetStream is
6552/// the reason this mode exists) a SIGKILL turns every ordinary teardown into a
6553/// recovery on the next start.
6554///
6555/// NEVER CALLED FOR A SUBC MODULE, and that is a rule rather than an
6556/// optimisation: a subc module's graceful stop is already running by the time a
6557/// child is drained, and a signal would race it.
6558///
6559/// Best-effort by construction. A child that has already exited is the ordinary
6560/// case rather than an error (the kill lands on a reaped or exiting pid), so a
6561/// failure is logged at debug and the wait-then-kill below still decides the
6562/// outcome.
6563#[cfg(unix)]
6564fn request_graceful_stop(module_id: &str, child: &SupervisedChild) {
6565    let Some(pid) = child
6566        .id()
6567        .and_then(|pid| i32::try_from(pid).ok())
6568        .and_then(rustix::process::Pid::from_raw)
6569    else {
6570        debug!(
6571            module_id,
6572            "no pid to signal for protocol: none teardown; falling through to the drain wait"
6573        );
6574        return;
6575    };
6576    match rustix::process::kill_process(pid, rustix::process::Signal::TERM) {
6577        Ok(()) => debug!(module_id, "sent SIGTERM to protocol: none module"),
6578        Err(err) => debug!(
6579            module_id,
6580            error = %err,
6581            "SIGTERM to protocol: none module failed; the drain wait and kill still apply"
6582        ),
6583    }
6584}
6585
6586/// Windows has no SIGTERM and no portable stand-in for one. The graceful stops
6587/// Windows does offer need cooperation this supervisor cannot assume: a console
6588/// control event requires sharing a console with the child, and `WM_CLOSE`
6589/// requires the child to pump a message loop. A supervised server process does
6590/// neither, so there is nothing to send and teardown is the wait followed by the
6591/// kill. Emulating a signal here would mean inventing a stop protocol, which is
6592/// the thing `protocol: "none"` exists to avoid.
6593#[cfg(not(unix))]
6594fn request_graceful_stop(module_id: &str, _child: &SupervisedChild) {
6595    debug!(
6596        module_id,
6597        "no graceful stop signal exists on this platform; protocol: none teardown waits, then kills"
6598    );
6599}
6600
6601fn terminal_disposition(final_state: ModuleState) -> TerminalDisposition {
6602    match final_state {
6603        ModuleState::Stopped => TerminalDisposition::Stopped,
6604        ModuleState::Disabled => TerminalDisposition::Disabled,
6605        ModuleState::Restarting => TerminalDisposition::Restarting,
6606        ModuleState::Failed => TerminalDisposition::Failed,
6607        ModuleState::Starting
6608        | ModuleState::Running
6609        | ModuleState::Unresponsive
6610        | ModuleState::Draining => {
6611            unreachable!("terminal exits only finish in terminal or restarting states")
6612        }
6613    }
6614}
6615
6616/// Wait for the ACTIVE registration of `module_id` to go away, which is what a
6617/// plain stop or restart waits for before it spawns a replacement.
6618async fn wait_for_registration_release(
6619    registry: &Registry,
6620    module_id: &str,
6621    wait: Duration,
6622) -> Result<(), SuperviseError> {
6623    wait_for_slot_registration_release(
6624        registry,
6625        crate::registry::RegistrationSlot::Active(module_id),
6626        wait,
6627    )
6628    .await
6629}
6630
6631/// Wait for the registration in `slot` to go away.
6632///
6633/// Keyed on the slot rather than the bare module id because a successful swap
6634/// never empties the id's active slot (the promoted candidate is in it), so an
6635/// id-keyed wait for the incumbent's release would always time out. Draining a
6636/// swap's incumbent waits on `crate::registry::RegistrationSlot::Connection` with the
6637/// incumbent's connection instead.
6638async fn wait_for_slot_registration_release(
6639    registry: &Registry,
6640    slot: crate::registry::RegistrationSlot<'_>,
6641    wait: Duration,
6642) -> Result<(), SuperviseError> {
6643    let deadline = Instant::now() + wait;
6644    let mut release_events = registration_release_events().subscribe();
6645    let still_active = |registration: &crate::registry::ModuleRegistration| {
6646        SuperviseError::RegistrationStillActive {
6647            module_id: registration.manifest.module_id.clone(),
6648            waited: wait,
6649        }
6650    };
6651    loop {
6652        let _observed_generation = *release_events.borrow_and_update();
6653        let Some(registration) = registry
6654            .registration(slot)
6655            .map_err(SuperviseError::Registry)?
6656        else {
6657            return Ok(());
6658        };
6659
6660        let now = Instant::now();
6661        if now >= deadline {
6662            return Err(still_active(&registration));
6663        }
6664
6665        let remaining = deadline.saturating_duration_since(now);
6666        match timeout(remaining, release_events.changed()).await {
6667            Ok(Ok(())) | Ok(Err(_)) => {}
6668            Err(_) => return Err(still_active(&registration)),
6669        }
6670    }
6671}
6672
6673#[cfg(test)]
6674mod slot_registration_wait_tests {
6675    use super::*;
6676    use crate::registry::{ConnectionId, RegistrationSlot};
6677    use subc_protocol::manifest::ModuleManifest;
6678
6679    const INCUMBENT: u64 = 1;
6680    const CANDIDATE: u64 = 2;
6681
6682    fn swapped_registry() -> Arc<Registry> {
6683        let registry = Arc::new(Registry::default());
6684        let manifest = ModuleManifest::builder("m", "0.1.0").build();
6685        registry
6686            .register_with_control_ops(
6687                manifest.clone(),
6688                1,
6689                ConnectionId::new(INCUMBENT),
6690                Vec::new(),
6691            )
6692            .unwrap();
6693        registry
6694            .register_candidate_with_control_ops(
6695                manifest,
6696                1,
6697                ConnectionId::new(CANDIDATE),
6698                Vec::new(),
6699            )
6700            .unwrap();
6701        registry
6702    }
6703
6704    /// After a promotion the id's active slot is held by the new process, so an
6705    /// id-keyed wait for the incumbent's release can never succeed; the
6706    /// connection-keyed wait completes as soon as the incumbent deregisters.
6707    #[tokio::test]
6708    async fn incumbent_release_is_awaited_by_connection_not_by_module_id() {
6709        let registry = swapped_registry();
6710        registry.promote_candidate("m").unwrap().unwrap();
6711
6712        assert!(matches!(
6713            wait_for_registration_release(&registry, "m", Duration::from_millis(50)).await,
6714            Err(SuperviseError::RegistrationStillActive { .. })
6715        ));
6716
6717        // Still held while the incumbent's connection has not deregistered.
6718        assert!(matches!(
6719            wait_for_slot_registration_release(
6720                &registry,
6721                RegistrationSlot::Connection(ConnectionId::new(INCUMBENT)),
6722                Duration::from_millis(50),
6723            )
6724            .await,
6725            Err(SuperviseError::RegistrationStillActive { .. })
6726        ));
6727
6728        let releaser = Arc::clone(&registry);
6729        let release = tokio::spawn(async move {
6730            sleep(Duration::from_millis(20)).await;
6731            releaser
6732                .deregister_connection(ConnectionId::new(INCUMBENT))
6733                .unwrap();
6734            notify_registration_release();
6735        });
6736        wait_for_slot_registration_release(
6737            &registry,
6738            RegistrationSlot::Connection(ConnectionId::new(INCUMBENT)),
6739            Duration::from_secs(5),
6740        )
6741        .await
6742        .expect("the incumbent's own registration is released");
6743        release.await.unwrap();
6744        assert!(registry.get_module("m").unwrap().is_some());
6745    }
6746
6747    /// The candidate slot is waited on separately from the active slot: the
6748    /// incumbent's registration neither holds up nor stands in for it.
6749    #[tokio::test]
6750    async fn candidate_slot_wait_ignores_the_incumbents_registration() {
6751        let registry = swapped_registry();
6752        assert!(matches!(
6753            wait_for_slot_registration_release(
6754                &registry,
6755                RegistrationSlot::Candidate("m"),
6756                Duration::from_millis(50),
6757            )
6758            .await,
6759            Err(SuperviseError::RegistrationStillActive { .. })
6760        ));
6761        registry
6762            .deregister_connection(ConnectionId::new(CANDIDATE))
6763            .unwrap();
6764        wait_for_slot_registration_release(
6765            &registry,
6766            RegistrationSlot::Candidate("m"),
6767            Duration::from_millis(50),
6768        )
6769        .await
6770        .expect("a candidate slot with no candidate is released");
6771        assert!(registry
6772            .registration(RegistrationSlot::Active("m"))
6773            .unwrap()
6774            .is_some());
6775    }
6776}
6777
6778fn classify_exit(status: &ExitStatus) -> ExitReport {
6779    ExitReport {
6780        kind: if status.success() {
6781            ExitKind::Clean
6782        } else {
6783            ExitKind::Crash
6784        },
6785        code: status.code(),
6786        signal: exit_signal(status),
6787        at_ms: unix_ms_now(),
6788    }
6789}
6790
6791/// The terminal record for a module whose `wait()` call itself errored (e.g. the
6792/// child was already reaped out-of-band). There is no `ExitStatus` to read a code
6793/// or signal from -- `None`/`None` is the honest shape, not a guess -- but the
6794/// disposition still must be `Failed` so the terminal ring is not silently missing
6795/// an entry, matching what `fail_snapshot` records for this same arm.
6796fn wait_error_exit_report() -> ExitReport {
6797    ExitReport {
6798        kind: ExitKind::Crash,
6799        code: None,
6800        signal: None,
6801        at_ms: unix_ms_now(),
6802    }
6803}
6804
6805#[cfg(unix)]
6806fn exit_signal(status: &ExitStatus) -> Option<i32> {
6807    use std::os::unix::process::ExitStatusExt;
6808
6809    status.signal()
6810}
6811
6812#[cfg(not(unix))]
6813fn exit_signal(_status: &ExitStatus) -> Option<i32> {
6814    None
6815}
6816
6817/// Give an operator-touched module its full crash budget back.
6818///
6819/// Named for the counter it used to zero; it now empties the in-window ring,
6820/// which is the same act. `lifetime_restarts` is untouched on purpose -- the
6821/// ledger of what happened survives every operator action.
6822fn reset_restart_count(snapshot: &SharedSnapshot, module_id: &str) -> Result<(), SuperviseError> {
6823    update_snapshot(snapshot, Some(module_id), |state| {
6824        state.clear_crash_restarts();
6825    })
6826}
6827
6828fn set_running(
6829    snapshot: &SharedSnapshot,
6830    child: &SupervisedChild,
6831    module_id: &str,
6832    spawn_events: &SpawnEventFeed,
6833) -> Result<(), SuperviseError> {
6834    let mut state = snapshot.lock().map_err(|_| SuperviseError::StatePoisoned {
6835        module_id: Some(module_id.to_string()),
6836    })?;
6837    state.spawn_generation = spawn_events.emit_spawned(module_id, child.pid, child.spawned_at_ms);
6838    // Every caller of this is a plain spawn, which always uses the primary key;
6839    // a promoted swap candidate sets the flag itself after this returns.
6840    state.in_alternate_slot = false;
6841    state.state = ModuleState::Running;
6842    state.enabled = true;
6843    state.process_alive = true;
6844    state.pid = child.id();
6845    state.spawned_at_ms = Some(child.spawned_at_ms);
6846    state.spawned_from = Some(child.spawned_from.clone());
6847    state.spawned_file_identity = child.spawned_file_identity;
6848    state.process_start_time = child.process_start_time;
6849    Ok(())
6850}
6851
6852fn clear_current_process_facts(state: &mut SupervisorSnapshot) {
6853    state.process_alive = false;
6854    state.pid = None;
6855    state.spawned_at_ms = None;
6856    state.spawned_from = None;
6857    state.spawned_file_identity = None;
6858    state.process_start_time = None;
6859    state.deliberate_severance = None;
6860}
6861
6862#[cfg(test)]
6863fn record_deliberate_severance(
6864    snapshot: &SharedSnapshot,
6865    identity: ProcessIdentity,
6866) -> Result<(), SuperviseError> {
6867    update_snapshot(snapshot, None, |state| {
6868        state.deliberate_severance = Some(identity);
6869    })
6870}
6871
6872fn apply_deliberate_severance_marker(
6873    snapshot: &SharedSnapshot,
6874    exited_identity: Option<ProcessIdentity>,
6875    mut exit_report: ExitReport,
6876) -> ExitReport {
6877    let marker = lock_snapshot(snapshot)
6878        .ok()
6879        .and_then(|mut state| state.deliberate_severance.take());
6880    if marker.is_some() && marker == exited_identity {
6881        exit_report.kind = ExitKind::DeliberateSeverance;
6882    }
6883    exit_report
6884}
6885
6886fn classify_reaped_child_exit(
6887    snapshot: &SharedSnapshot,
6888    child: &SupervisedChild,
6889    status: &ExitStatus,
6890) -> ExitReport {
6891    apply_deliberate_severance_marker(snapshot, child.process_identity(), classify_exit(status))
6892}
6893
6894fn fail_snapshot(
6895    snapshot: &SharedSnapshot,
6896    module_id: Option<&str>,
6897    last_exit: Option<ExitReport>,
6898) {
6899    if let Err(err) = update_snapshot(snapshot, module_id, |state| {
6900        state.state = ModuleState::Failed;
6901        clear_current_process_facts(state);
6902        if let Some(last_exit) = last_exit {
6903            state.last_exit = Some(last_exit);
6904        }
6905    }) {
6906        error!(error = %err, "failed to mark supervisor state failed");
6907    }
6908}
6909
6910fn update_snapshot(
6911    snapshot: &SharedSnapshot,
6912    module_id: Option<&str>,
6913    update: impl FnOnce(&mut SupervisorSnapshot),
6914) -> Result<(), SuperviseError> {
6915    let mut state = snapshot.lock().map_err(|_| SuperviseError::StatePoisoned {
6916        module_id: module_id.map(ToOwned::to_owned),
6917    })?;
6918    update(&mut state);
6919    Ok(())
6920}
6921
6922const SLOW_SNAPSHOT_LOCK_THRESHOLD: Duration = Duration::from_millis(250);
6923
6924fn lock_snapshot_for_control<'a>(
6925    snapshot: &'a SharedSnapshot,
6926    module_id: &str,
6927    caller: &'static str,
6928) -> Result<std::sync::MutexGuard<'a, SupervisorSnapshot>, SuperviseError> {
6929    let started_at = Instant::now();
6930    let guard = lock_snapshot(snapshot)?;
6931    let waited = started_at.elapsed();
6932    if waited >= SLOW_SNAPSHOT_LOCK_THRESHOLD {
6933        warn!(
6934            module_id = %module_id,
6935            waited_ms = waited.as_millis() as u64,
6936            caller = %caller,
6937            "slow snapshot lock"
6938        );
6939    }
6940    Ok(guard)
6941}
6942
6943fn lock_snapshot(
6944    snapshot: &SharedSnapshot,
6945) -> Result<std::sync::MutexGuard<'_, SupervisorSnapshot>, SuperviseError> {
6946    snapshot
6947        .lock()
6948        .map_err(|_| SuperviseError::StatePoisoned { module_id: None })
6949}
6950
6951#[cfg(test)]
6952mod terminal_history_tests {
6953    use std::{
6954        path::PathBuf,
6955        sync::Arc,
6956        time::{Duration, Instant},
6957    };
6958
6959    use tokio::time::sleep;
6960
6961    use super::{
6962        apply_deliberate_severance_marker, daemon_will_restart, drain_child_to_state,
6963        drained_after_quiescence_wait, handle_reload_spawn_failure, health_restart_child,
6964        lock_snapshot, on_child_exit, record_deliberate_severance, record_wait_error_terminal,
6965        reset_restart_count, spawn_and_mark_running, update_snapshot, wait_error_exit_report,
6966        ExitKind, ExitReport, ModuleProtocol, ModuleSpec, ModuleState, NextAction, ProcessIdentity,
6967        RestartPolicy, SpawnEventKind, SuperviseError, SupervisedModule, Supervisor,
6968        SupervisorHandle, SupervisorHealthStatus, SupervisorSnapshot,
6969    };
6970    // The supervisor's clock, distinct from the `std::time::Instant` these tests
6971    // use for their own wall-clock deadlines: crash-restart instants must be on
6972    // the same clock the production code stamps them with, which is tokio's (and
6973    // is what `start_paused` tests can move).
6974    use super::Instant as ClockInstant;
6975    use crate::{
6976        registry::Registry,
6977        terminal_ring::{TerminalRing, TerminalRingConfig},
6978    };
6979    use std::sync::Mutex;
6980    use subc_control::TerminalDisposition;
6981
6982    /// See the twin in `control.rs` for why this derives the path from
6983    /// `current_exe()` and why the existence check is here: `--lib` alone does
6984    /// not build `[[bin]]` targets, and a bare spawn then fails with a raw
6985    /// `NotFound` that reads as a broken test rather than an unbuilt dependency.
6986    fn fake_aft_stub_path() -> PathBuf {
6987        let mut path = std::env::current_exe().expect("current_exe available in tests");
6988        path.pop();
6989        path.pop();
6990        path.push(if cfg!(windows) {
6991            "fake-aft-stub.exe"
6992        } else {
6993            "fake-aft-stub"
6994        });
6995        assert!(
6996            path.exists(),
6997            "fake-aft-stub not built at {}: run `cargo test -p subc-core` (which builds \
6998             [[bin]] targets) rather than `cargo test -p subc-core --lib` (which does not)",
6999            path.display()
7000        );
7001        path
7002    }
7003
7004    #[test]
7005    fn reserved_never_spawned_refuses_every_hello() {
7006        // The canary hole: a reserved id whose module has never spawned had NO
7007        // gate entry and admitted anyone -- the reservation protected the nonce
7008        // holder, not the NAME. Now the entry is present with no legitimate
7009        // holder and refuses all comers.
7010        let supervisor = SupervisorHandle::default();
7011        supervisor.apply_identity_configuration(&ModuleSpec {
7012            module_id: "never-spawned".to_string(),
7013            program: PathBuf::from("/usr/bin/false"),
7014            args: Vec::new(),
7015            env: Vec::new(),
7016            reserved: true,
7017            reserved_prefixes: Vec::new(),
7018            protocol: ModuleProtocol::Subc,
7019            overlap: Default::default(),
7020        });
7021        assert!(
7022            supervisor
7023                .reserved_hello_rejection("never-spawned", Some("any-forged-nonce"))
7024                .is_some(),
7025            "forged nonce must refuse on a reserved never-spawned id"
7026        );
7027        assert!(
7028            supervisor
7029                .reserved_hello_rejection("never-spawned", None)
7030                .is_some(),
7031            "absent nonce must refuse on a reserved never-spawned id"
7032        );
7033        // And a real spawn nonce minted later admits exactly that nonce.
7034        supervisor.set_spawn_nonce("never-spawned", "minted".to_string());
7035        supervisor.apply_identity_configuration(&ModuleSpec {
7036            module_id: "never-spawned".to_string(),
7037            program: PathBuf::from("/usr/bin/false"),
7038            args: Vec::new(),
7039            env: Vec::new(),
7040            reserved: true,
7041            reserved_prefixes: Vec::new(),
7042            protocol: ModuleProtocol::Subc,
7043            overlap: Default::default(),
7044        });
7045        assert!(supervisor
7046            .reserved_hello_rejection("never-spawned", Some("minted"))
7047            .is_none());
7048        assert!(supervisor
7049            .reserved_hello_rejection("never-spawned", Some("forged"))
7050            .is_some());
7051    }
7052
7053    /// Put `count` crash restarts on a snapshot's ring as if they had all just
7054    /// happened, which is what "spent budget" looks like to every reader.
7055    fn seed_crash_restarts(state: &mut SupervisorSnapshot, count: u32) {
7056        let now = ClockInstant::now();
7057        for _ in 0..count {
7058            state.crash_restarts.push_back(now);
7059        }
7060    }
7061
7062    /// Age the oldest recorded restart out of `window`, standing in for the hours
7063    /// that would otherwise have to pass. Injecting the instant is the point: a
7064    /// test that slept a real window would take ten minutes and still prove less.
7065    fn age_oldest_crash_restart_out_of_window(state: &mut SupervisorSnapshot, window: Duration) {
7066        let aged = state
7067            .crash_restarts
7068            .front()
7069            .expect("a crash restart must be recorded before it can be aged")
7070            .checked_sub(window + Duration::from_secs(1))
7071            .expect("the test clock is far enough from its origin to age an instant");
7072        state.crash_restarts[0] = aged;
7073    }
7074
7075    fn snapshot_with_restarts(enabled: bool, count: u32) -> SupervisorSnapshot {
7076        let mut state = SupervisorSnapshot::new(ModuleState::Running, enabled);
7077        seed_crash_restarts(&mut state, count);
7078        state
7079    }
7080
7081    #[test]
7082    fn daemon_owned_recovery_predicate_uses_the_pre_increment_budget() {
7083        let policy = RestartPolicy::new(3, Duration::ZERO);
7084        let now = ClockInstant::now();
7085        assert!(daemon_will_restart(
7086            &mut snapshot_with_restarts(true, 2),
7087            &policy,
7088            now
7089        ));
7090        assert!(!daemon_will_restart(
7091            &mut snapshot_with_restarts(true, 3),
7092            &policy,
7093            now
7094        ));
7095        assert!(!daemon_will_restart(
7096            &mut snapshot_with_restarts(false, 0),
7097            &policy,
7098            now
7099        ));
7100    }
7101
7102    #[test]
7103    fn crash_restart_backoff_escalates_with_in_window_count() {
7104        let policy = RestartPolicy::new(4, Duration::from_millis(100))
7105            .with_max_backoff(Duration::from_secs(30));
7106        let now = ClockInstant::now();
7107        let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
7108        let schedules = (0..4)
7109            .map(|_| {
7110                state
7111                    .next_crash_restart(&policy, now)
7112                    .expect("the test policy allows four crash restarts")
7113            })
7114            .collect::<Vec<_>>();
7115
7116        assert_eq!(
7117            schedules
7118                .iter()
7119                .map(|schedule| schedule.restart_in_window)
7120                .collect::<Vec<_>>(),
7121            vec![0, 1, 2, 3]
7122        );
7123        assert_eq!(
7124            schedules
7125                .iter()
7126                .map(|schedule| schedule.delay)
7127                .collect::<Vec<_>>(),
7128            vec![
7129                Duration::from_millis(100),
7130                Duration::from_secs(1),
7131                Duration::from_secs(10),
7132                Duration::from_secs(30),
7133            ]
7134        );
7135    }
7136
7137    #[test]
7138    fn crash_restart_backoff_resets_after_ring_clear() {
7139        let policy = RestartPolicy::new(3, Duration::from_millis(100));
7140        let now = ClockInstant::now();
7141        let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
7142        assert_eq!(
7143            state.next_crash_restart(&policy, now).unwrap().delay,
7144            Duration::from_millis(100)
7145        );
7146        assert_eq!(
7147            state.next_crash_restart(&policy, now).unwrap().delay,
7148            Duration::from_secs(1)
7149        );
7150
7151        state.clear_crash_restarts();
7152        let schedule = state
7153            .next_crash_restart(&policy, now)
7154            .expect("a cleared ring must allow another restart");
7155        assert_eq!(schedule.restart_in_window, 0);
7156        assert_eq!(schedule.delay, Duration::from_millis(100));
7157    }
7158
7159    #[test]
7160    fn crash_restart_backoff_ignores_aged_restarts() {
7161        let policy = RestartPolicy::new(3, Duration::from_millis(100));
7162        let now = ClockInstant::now();
7163        let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
7164        state
7165            .next_crash_restart(&policy, now)
7166            .expect("the first restart is allowed");
7167        state
7168            .next_crash_restart(&policy, now)
7169            .expect("the second restart is allowed");
7170        state.crash_restarts[0] = now
7171            .checked_sub(policy.window + Duration::from_secs(1))
7172            .expect("the fake clock can age a restart past the window");
7173
7174        let schedule = state
7175            .next_crash_restart(&policy, now)
7176            .expect("an aged restart must release its slot");
7177        assert_eq!(schedule.restart_in_window, 1);
7178        assert_eq!(schedule.delay, Duration::from_secs(1));
7179        assert_eq!(state.crash_restarts.len(), 2);
7180    }
7181
7182    /// The budget is a rate: the same three spent restarts refuse a respawn
7183    /// while they are recent and allow one once they have aged past the window.
7184    /// Nothing about the module changed in between, which is the whole point.
7185    #[test]
7186    fn a_budget_spent_before_the_window_no_longer_refuses() {
7187        let policy = RestartPolicy::new(3, Duration::ZERO);
7188        let mut state = snapshot_with_restarts(true, 3);
7189        let now = ClockInstant::now();
7190        assert!(!daemon_will_restart(&mut state, &policy, now));
7191
7192        assert!(daemon_will_restart(
7193            &mut state,
7194            &policy,
7195            now + policy.window + Duration::from_secs(1)
7196        ));
7197        assert!(
7198            state.crash_restarts.is_empty(),
7199            "reading the budget must drop the instants that left the window"
7200        );
7201    }
7202
7203    fn module_with_recovery_snapshot(
7204        state: ModuleState,
7205        enabled: bool,
7206        restart_count: u32,
7207    ) -> SupervisedModule {
7208        let registry = Arc::new(Registry::default());
7209        let supervisor =
7210            Supervisor::new(Arc::clone(&registry), RestartPolicy::new(3, Duration::ZERO));
7211        let module = supervisor
7212            .spawn(ModuleSpec {
7213                module_id: "recovery-snapshot".to_string(),
7214                program: fake_aft_stub_path(),
7215                args: Vec::new(),
7216                env: Vec::new(),
7217                reserved: false,
7218                reserved_prefixes: Vec::new(),
7219                protocol: ModuleProtocol::Subc,
7220                overlap: Default::default(),
7221            })
7222            .unwrap();
7223        update_snapshot(
7224            &module.inner.snapshot,
7225            Some("recovery-snapshot"),
7226            |snapshot| {
7227                snapshot.state = state;
7228                snapshot.enabled = enabled;
7229                seed_crash_restarts(snapshot, restart_count);
7230            },
7231        )
7232        .unwrap();
7233        module
7234    }
7235
7236    #[cfg(target_os = "linux")]
7237    #[tokio::test]
7238    async fn no_cgroup_placement_does_not_block_fake_aft_stub_spawn() {
7239        let supervisor = Supervisor::new(Arc::new(Registry::default()), RestartPolicy::default())
7240            .with_cgroup_placement(None);
7241        let result = supervisor.spawn(ModuleSpec {
7242            module_id: "no-cgroup-placement".to_string(),
7243            program: fake_aft_stub_path(),
7244            args: Vec::new(),
7245            env: Vec::new(),
7246            reserved: false,
7247            reserved_prefixes: Vec::new(),
7248            protocol: ModuleProtocol::Subc,
7249            overlap: Default::default(),
7250        });
7251
7252        assert!(
7253            result.is_ok(),
7254            "no delegation must not turn an otherwise valid spawn into a failure: {result:?}"
7255        );
7256    }
7257
7258    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7259    async fn undecided_snapshot_uses_shared_restart_predicate() {
7260        assert!(module_with_recovery_snapshot(ModuleState::Running, true, 2)
7261            .will_recover_after_connection_loss()
7262            .unwrap());
7263        assert!(
7264            !module_with_recovery_snapshot(ModuleState::Running, true, 3)
7265                .will_recover_after_connection_loss()
7266                .unwrap()
7267        );
7268    }
7269
7270    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7271    async fn restarting_snapshot_at_exhausted_budget_is_non_terminal() {
7272        assert!(
7273            module_with_recovery_snapshot(ModuleState::Restarting, true, 3)
7274                .will_recover_after_connection_loss()
7275                .unwrap()
7276        );
7277    }
7278
7279    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7280    async fn terminal_phase_snapshots_are_terminal_before_budget_exhaustion() {
7281        assert!(!module_with_recovery_snapshot(ModuleState::Failed, true, 0)
7282            .will_recover_after_connection_loss()
7283            .unwrap());
7284        assert!(
7285            !module_with_recovery_snapshot(ModuleState::Disabled, true, 0)
7286                .will_recover_after_connection_loss()
7287                .unwrap()
7288        );
7289    }
7290
7291    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7292    async fn warming_snapshot_is_limited_to_startup_phases() {
7293        for state in [
7294            ModuleState::Starting,
7295            ModuleState::Running,
7296            ModuleState::Restarting,
7297        ] {
7298            assert!(
7299                module_with_recovery_snapshot(state, true, 0)
7300                    .is_warming()
7301                    .unwrap(),
7302                "{state:?} should be warming"
7303            );
7304        }
7305        for state in [
7306            ModuleState::Unresponsive,
7307            ModuleState::Draining,
7308            ModuleState::Stopped,
7309            ModuleState::Failed,
7310            ModuleState::Disabled,
7311        ] {
7312            assert!(
7313                !module_with_recovery_snapshot(state, true, 0)
7314                    .is_warming()
7315                    .unwrap(),
7316                "{state:?} should not be warming"
7317            );
7318        }
7319    }
7320
7321    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7322    async fn terminal_history_survives_respawn_and_keeps_both_crashes_in_order() {
7323        let registry = Arc::new(Registry::default());
7324        let supervisor =
7325            Supervisor::new(Arc::clone(&registry), RestartPolicy::new(1, Duration::ZERO));
7326        let module = supervisor
7327            .spawn(ModuleSpec {
7328                module_id: "terminal-history".to_string(),
7329                program: fake_aft_stub_path(),
7330                args: Vec::new(),
7331                env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
7332                reserved: false,
7333                reserved_prefixes: Vec::new(),
7334                protocol: ModuleProtocol::Subc,
7335                overlap: Default::default(),
7336            })
7337            .unwrap();
7338
7339        let deadline = Instant::now() + Duration::from_secs(5);
7340        loop {
7341            let history = module.terminal_history();
7342            if history.entries.len() == 2 {
7343                assert_eq!(module.status().unwrap().state, ModuleState::Failed);
7344                assert_eq!(history.dropped, 0);
7345                assert_eq!(
7346                    history
7347                        .entries
7348                        .iter()
7349                        .map(|entry| entry.exit_code)
7350                        .collect::<Vec<_>>(),
7351                    vec![Some(23), Some(23)]
7352                );
7353                assert!(history.entries[0].at_ms <= history.entries[1].at_ms);
7354                return;
7355            }
7356            assert!(
7357                Instant::now() < deadline,
7358                "module did not retain two terminal exits: {history:?}"
7359            );
7360            sleep(Duration::from_millis(10)).await;
7361        }
7362    }
7363
7364    /// A disable issued while a crash respawn is still backing off must preempt
7365    /// that respawn: the operator's stop wins, the disable must not queue behind
7366    /// the backoff, and the module must never come back up afterwards.
7367    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7368    async fn disable_during_crash_backoff_cancels_pending_respawn() {
7369        let backoff = Duration::from_secs(2);
7370        let supervisor = Supervisor::new(
7371            Arc::new(Registry::default()),
7372            RestartPolicy::new(10, backoff),
7373        );
7374        let module = supervisor
7375            .spawn(ModuleSpec {
7376                module_id: "disable-during-backoff".to_string(),
7377                program: fake_aft_stub_path(),
7378                args: Vec::new(),
7379                env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
7380                reserved: false,
7381                reserved_prefixes: Vec::new(),
7382                protocol: ModuleProtocol::Subc,
7383                overlap: Default::default(),
7384            })
7385            .unwrap();
7386
7387        // Wait for the first crash to put the module into its backoff window.
7388        let deadline = Instant::now() + Duration::from_secs(5);
7389        loop {
7390            if module.status().unwrap().state == ModuleState::Restarting {
7391                break;
7392            }
7393            assert!(
7394                Instant::now() < deadline,
7395                "module never entered the crash backoff"
7396            );
7397            sleep(Duration::from_millis(10)).await;
7398        }
7399
7400        let started = Instant::now();
7401        module.set_enabled(false).await.unwrap();
7402        let waited = started.elapsed();
7403
7404        assert!(
7405            waited < backoff / 2,
7406            "disable waited {waited:?} behind the {backoff:?} crash backoff; the operator command must preempt the pending respawn"
7407        );
7408        assert_eq!(module.status().unwrap().state, ModuleState::Disabled);
7409
7410        // Outlast the backoff: the respawn it was counting down to must never run.
7411        sleep(backoff + Duration::from_millis(500)).await;
7412        let status = module.status().unwrap();
7413        assert_eq!(status.state, ModuleState::Disabled);
7414        assert_eq!(
7415            status.spawn_generation, 1,
7416            "module respawned after the operator disabled it"
7417        );
7418    }
7419
7420    /// Each restart-producing arm has its own state transition. Keeping their
7421    /// lifetime count assertions adjacent prevents a later new arm from silently
7422    /// spending budget without recording the historical restart.
7423    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7424    async fn every_restart_increment_path_advances_lifetime_count() {
7425        let supervisor = Supervisor::new(
7426            Arc::new(Registry::default()),
7427            RestartPolicy::new(1, Duration::ZERO),
7428        );
7429        let runtime = supervisor.runtime_config();
7430        let spec = ModuleSpec {
7431            module_id: "lifetime-increment-path".to_string(),
7432            program: PathBuf::from("/unused/lifetime-increment-path"),
7433            args: Vec::new(),
7434            env: Vec::new(),
7435            reserved: false,
7436            reserved_prefixes: Vec::new(),
7437            protocol: ModuleProtocol::Subc,
7438            overlap: Default::default(),
7439        };
7440
7441        let crash_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
7442        assert!(matches!(
7443            on_child_exit(
7444                &spec,
7445                runtime.restart_policy,
7446                &supervisor.registry,
7447                &crash_snapshot,
7448                &runtime.terminal_ring,
7449                &runtime.spawn_events,
7450                ExitReport {
7451                    kind: ExitKind::Crash,
7452                    code: Some(1),
7453                    signal: None,
7454                    at_ms: 1,
7455                },
7456            )
7457            .await,
7458            NextAction::Restart { schedule: _ }
7459        ));
7460        let (crash_restarts, crash_lifetime) = {
7461            let state = lock_snapshot(&crash_snapshot).unwrap();
7462            (state.crash_restarts.len(), state.lifetime_restarts)
7463        };
7464        assert_eq!(crash_restarts, 1);
7465        assert_eq!(crash_lifetime, 1);
7466
7467        let health_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
7468        let mut health_child = None;
7469        assert!(matches!(
7470            health_restart_child(
7471                &spec,
7472                &runtime,
7473                &supervisor.registry,
7474                &supervisor.process_liveness,
7475                &health_snapshot,
7476                &mut health_child,
7477                SupervisorHealthStatus::Failing,
7478                None,
7479                2,
7480            )
7481            .await,
7482            Err(SuperviseError::Spawn { .. })
7483        ));
7484        let (health_restarts, health_lifetime) = {
7485            let state = lock_snapshot(&health_snapshot).unwrap();
7486            (state.crash_restarts.len(), state.lifetime_restarts)
7487        };
7488        assert_eq!(health_restarts, 1);
7489        assert_eq!(health_lifetime, 1);
7490
7491        let reload_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
7492        let mut reload_child = None;
7493        assert!(matches!(
7494            handle_reload_spawn_failure(
7495                &spec,
7496                &runtime,
7497                &supervisor.process_liveness,
7498                &reload_snapshot,
7499                &mut reload_child,
7500                "forced reload spawn failure".to_string(),
7501            )
7502            .await,
7503            Err(SuperviseError::ReloadFailed { .. })
7504        ));
7505        let (reload_restarts, reload_lifetime) = {
7506            let state = lock_snapshot(&reload_snapshot).unwrap();
7507            (state.crash_restarts.len(), state.lifetime_restarts)
7508        };
7509        assert_eq!(reload_restarts, 1);
7510        assert_eq!(reload_lifetime, 1);
7511    }
7512
7513    #[tokio::test]
7514    async fn deliberately_severed_live_child_records_lifetime_without_spending_restart_budget() {
7515        let supervisor = Supervisor::new(
7516            Arc::new(Registry::default()),
7517            RestartPolicy::new(3, Duration::ZERO),
7518        );
7519        let runtime = supervisor.runtime_config();
7520        let spec = ModuleSpec {
7521            module_id: "deliberately-severed".to_string(),
7522            program: PathBuf::from("/unused/deliberately-severed"),
7523            args: Vec::new(),
7524            env: Vec::new(),
7525            reserved: false,
7526            reserved_prefixes: Vec::new(),
7527            protocol: ModuleProtocol::Subc,
7528            overlap: Default::default(),
7529        };
7530        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
7531        let process = ProcessIdentity {
7532            pid: 41,
7533            start_time: 101,
7534        };
7535        record_deliberate_severance(&snapshot, process).unwrap();
7536        let exit_report = apply_deliberate_severance_marker(
7537            &snapshot,
7538            Some(process),
7539            ExitReport {
7540                kind: ExitKind::Crash,
7541                code: Some(1),
7542                signal: None,
7543                at_ms: 1,
7544            },
7545        );
7546        assert_eq!(exit_report.kind, ExitKind::DeliberateSeverance);
7547
7548        assert!(matches!(
7549            on_child_exit(
7550                &spec,
7551                runtime.restart_policy,
7552                &supervisor.registry,
7553                &snapshot,
7554                &runtime.terminal_ring,
7555                &runtime.spawn_events,
7556                exit_report,
7557            )
7558            .await,
7559            NextAction::Restart { schedule: _ }
7560        ));
7561        let state = lock_snapshot(&snapshot).unwrap();
7562        assert_eq!(state.lifetime_restarts, 1);
7563        assert_eq!(state.crash_restarts.len(), 0);
7564    }
7565
7566    #[tokio::test]
7567    async fn genuine_crash_spends_restart_budget_and_records_lifetime() {
7568        let supervisor = Supervisor::new(
7569            Arc::new(Registry::default()),
7570            RestartPolicy::new(3, Duration::ZERO),
7571        );
7572        let runtime = supervisor.runtime_config();
7573        let spec = ModuleSpec {
7574            module_id: "genuine-crash".to_string(),
7575            program: PathBuf::from("/unused/genuine-crash"),
7576            args: Vec::new(),
7577            env: Vec::new(),
7578            reserved: false,
7579            reserved_prefixes: Vec::new(),
7580            protocol: ModuleProtocol::Subc,
7581            overlap: Default::default(),
7582        };
7583        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
7584
7585        assert!(matches!(
7586            on_child_exit(
7587                &spec,
7588                runtime.restart_policy,
7589                &supervisor.registry,
7590                &snapshot,
7591                &runtime.terminal_ring,
7592                &runtime.spawn_events,
7593                ExitReport {
7594                    kind: ExitKind::Crash,
7595                    code: Some(1),
7596                    signal: None,
7597                    at_ms: 1,
7598                },
7599            )
7600            .await,
7601            NextAction::Restart { schedule: _ }
7602        ));
7603        let state = lock_snapshot(&snapshot).unwrap();
7604        assert_eq!(state.lifetime_restarts, 1);
7605        assert_eq!(state.crash_restarts.len(), 1);
7606    }
7607
7608    fn crash_exit_report(at_ms: u64) -> ExitReport {
7609        ExitReport {
7610            kind: ExitKind::Crash,
7611            code: Some(1),
7612            signal: None,
7613            at_ms,
7614        }
7615    }
7616
7617    fn windowed_crash_spec(module_id: &str) -> ModuleSpec {
7618        ModuleSpec {
7619            module_id: module_id.to_string(),
7620            program: PathBuf::from("/unused").join(module_id),
7621            args: Vec::new(),
7622            env: Vec::new(),
7623            reserved: false,
7624            reserved_prefixes: Vec::new(),
7625            protocol: ModuleProtocol::Subc,
7626            overlap: Default::default(),
7627        }
7628    }
7629
7630    /// A real crash loop still stops. Three crashes with nothing aging out spend
7631    /// a budget of two and the third respawn is refused, and both surfaces an
7632    /// operator has -- the log line and the retained terminal record -- name the
7633    /// window rather than only the cap, because `max_restarts=2` alone is what
7634    /// this budget used to mean.
7635    #[tokio::test]
7636    async fn three_crashes_inside_the_window_stop_the_module_and_name_the_window() {
7637        let (logs, _guard) = crate::router::test_log::log_capture(tracing::Level::ERROR);
7638        let supervisor = Supervisor::new(
7639            Arc::new(Registry::default()),
7640            RestartPolicy::new(2, Duration::ZERO),
7641        );
7642        let runtime = supervisor.runtime_config();
7643        let spec = windowed_crash_spec("crash-loop-in-window");
7644        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
7645
7646        for attempt in 1..=2 {
7647            assert!(
7648                matches!(
7649                    on_child_exit(
7650                        &spec,
7651                        runtime.restart_policy,
7652                        &supervisor.registry,
7653                        &snapshot,
7654                        &runtime.terminal_ring,
7655                        &runtime.spawn_events,
7656                        crash_exit_report(attempt),
7657                    )
7658                    .await,
7659                    NextAction::Restart { schedule: _ }
7660                ),
7661                "crash {attempt} is inside the budget and must respawn"
7662            );
7663        }
7664
7665        assert!(matches!(
7666            on_child_exit(
7667                &spec,
7668                runtime.restart_policy,
7669                &supervisor.registry,
7670                &snapshot,
7671                &runtime.terminal_ring,
7672                &runtime.spawn_events,
7673                crash_exit_report(3),
7674            )
7675            .await,
7676            NextAction::Stop { .. }
7677        ));
7678
7679        {
7680            let state = lock_snapshot(&snapshot).unwrap();
7681            assert_eq!(state.state, ModuleState::Failed);
7682            assert_eq!(state.crash_restarts.len(), 2);
7683            assert_eq!(state.lifetime_restarts, 2);
7684        }
7685
7686        let history = runtime
7687            .terminal_ring
7688            .lock()
7689            .expect("terminal ring is not poisoned")
7690            .snapshot();
7691        let last = history
7692            .entries
7693            .last()
7694            .expect("the refused crash is retained");
7695        assert_eq!(last.disposition, TerminalDisposition::Failed);
7696        assert_eq!(
7697            last.disposition_detail.as_deref(),
7698            Some("crash budget exhausted: max_restarts=2 within window_secs=600")
7699        );
7700
7701        let captured = crate::router::test_log::captured_logs(&logs);
7702        assert!(
7703            captured.contains("crash budget exhausted: max_restarts=2 within window_secs=600"),
7704            "the stop must be logged with its window: {captured}"
7705        );
7706    }
7707
7708    /// The rate, stated as a test: three crashes where the first has aged past
7709    /// the window are two crashes as far as the budget is concerned, so the
7710    /// third respawn is allowed and the ring holds only the two recent ones.
7711    ///
7712    /// This is the case a lifetime counter got wrong -- and the case the daemon
7713    /// now hits routinely, since a module exits non-zero every time its
7714    /// connection to the daemon drops.
7715    #[tokio::test]
7716    async fn a_crash_older_than_the_window_frees_its_slot_for_a_later_crash() {
7717        let supervisor = Supervisor::new(
7718            Arc::new(Registry::default()),
7719            RestartPolicy::new(2, Duration::ZERO),
7720        );
7721        let runtime = supervisor.runtime_config();
7722        let spec = windowed_crash_spec("crash-across-windows");
7723        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
7724
7725        for attempt in 1..=2 {
7726            assert!(matches!(
7727                on_child_exit(
7728                    &spec,
7729                    runtime.restart_policy,
7730                    &supervisor.registry,
7731                    &snapshot,
7732                    &runtime.terminal_ring,
7733                    &runtime.spawn_events,
7734                    crash_exit_report(attempt),
7735                )
7736                .await,
7737                NextAction::Restart { schedule: _ }
7738            ));
7739        }
7740
7741        // The oldest crash moves out of the window; nothing else about the
7742        // module changes.
7743        update_snapshot(&snapshot, Some(&spec.module_id), |state| {
7744            age_oldest_crash_restart_out_of_window(state, runtime.restart_policy.window);
7745        })
7746        .unwrap();
7747
7748        assert!(
7749            matches!(
7750                on_child_exit(
7751                    &spec,
7752                    runtime.restart_policy,
7753                    &supervisor.registry,
7754                    &snapshot,
7755                    &runtime.terminal_ring,
7756                    &runtime.spawn_events,
7757                    crash_exit_report(3),
7758                )
7759                .await,
7760                NextAction::Restart { schedule: _ }
7761            ),
7762            "a crash older than the window must not hold a budget slot"
7763        );
7764
7765        let state = lock_snapshot(&snapshot).unwrap();
7766        assert_eq!(state.state, ModuleState::Restarting);
7767        assert_eq!(
7768            state.crash_restarts.len(),
7769            2,
7770            "the aged instant is dropped and the new one takes its place"
7771        );
7772        assert_eq!(
7773            state.lifetime_restarts, 3,
7774            "the ledger counts every restart, including the ones the window forgot"
7775        );
7776    }
7777
7778    /// An operator restart hands the budget back whole, and the ledger keeps
7779    /// counting. Those are different questions -- "how close is this module to
7780    /// being stopped" and "how many times has it been replaced" -- and the
7781    /// operator action answers only the first.
7782    #[tokio::test]
7783    async fn an_operator_restart_clears_the_ring_and_leaves_the_ledger_alone() {
7784        let supervisor = Supervisor::new(
7785            Arc::new(Registry::default()),
7786            RestartPolicy::new(2, Duration::ZERO),
7787        );
7788        let runtime = supervisor.runtime_config();
7789        let spec = windowed_crash_spec("operator-cleared-budget");
7790        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
7791
7792        for attempt in 1..=2 {
7793            assert!(matches!(
7794                on_child_exit(
7795                    &spec,
7796                    runtime.restart_policy,
7797                    &supervisor.registry,
7798                    &snapshot,
7799                    &runtime.terminal_ring,
7800                    &runtime.spawn_events,
7801                    crash_exit_report(attempt),
7802                )
7803                .await,
7804                NextAction::Restart { schedule: _ }
7805            ));
7806        }
7807
7808        reset_restart_count(&snapshot, &spec.module_id).unwrap();
7809        {
7810            let state = lock_snapshot(&snapshot).unwrap();
7811            assert!(
7812                state.crash_restarts.is_empty(),
7813                "an operator restart returns the full budget"
7814            );
7815            assert_eq!(
7816                state.lifetime_restarts, 2,
7817                "clearing the budget must not unmake the crashes"
7818            );
7819        }
7820
7821        assert!(
7822            matches!(
7823                on_child_exit(
7824                    &spec,
7825                    runtime.restart_policy,
7826                    &supervisor.registry,
7827                    &snapshot,
7828                    &runtime.terminal_ring,
7829                    &runtime.spawn_events,
7830                    crash_exit_report(3),
7831                )
7832                .await,
7833                NextAction::Restart { schedule: _ }
7834            ),
7835            "the cleared budget must be spendable again"
7836        );
7837        let state = lock_snapshot(&snapshot).unwrap();
7838        assert_eq!(state.crash_restarts.len(), 1);
7839        assert_eq!(state.lifetime_restarts, 3);
7840    }
7841
7842    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7843    async fn severance_marker_for_a_dead_child_does_not_label_its_successor() {
7844        let severed = ProcessIdentity {
7845            pid: 41,
7846            start_time: 101,
7847        };
7848        let successor = ProcessIdentity {
7849            pid: 41,
7850            start_time: 202,
7851        };
7852        let module = module_with_recovery_snapshot(ModuleState::Running, true, 0);
7853        update_snapshot(&module.inner.snapshot, Some("recovery-snapshot"), |state| {
7854            state.pid = Some(successor.pid);
7855            state.process_start_time = Some(successor.start_time);
7856        })
7857        .unwrap();
7858        assert!(!module.record_deliberate_severance(severed).unwrap());
7859
7860        let exit_report = apply_deliberate_severance_marker(
7861            &module.inner.snapshot,
7862            Some(successor),
7863            ExitReport {
7864                kind: ExitKind::Crash,
7865                code: Some(1),
7866                signal: None,
7867                at_ms: 1,
7868            },
7869        );
7870
7871        assert_eq!(exit_report.kind, ExitKind::Crash);
7872    }
7873
7874    #[tokio::test]
7875    async fn drain_reap_marks_deliberate_severance_and_records_lifetime_without_budget() {
7876        let registry = Registry::default();
7877        let supervisor = Supervisor::new(
7878            Arc::new(Registry::default()),
7879            RestartPolicy::new(3, Duration::ZERO),
7880        );
7881        let runtime = supervisor.runtime_config();
7882        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
7883        let spec = ModuleSpec {
7884            module_id: "drain-deliberate-severance".to_string(),
7885            program: fake_aft_stub_path(),
7886            args: Vec::new(),
7887            env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
7888            reserved: false,
7889            reserved_prefixes: Vec::new(),
7890            protocol: ModuleProtocol::Subc,
7891            overlap: Default::default(),
7892        };
7893        let mut child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
7894        let process = ProcessIdentity {
7895            pid: 41,
7896            start_time: 101,
7897        };
7898        child.process_identity = Some(process);
7899        update_snapshot(&snapshot, Some(&spec.module_id), |state| {
7900            state.pid = Some(process.pid);
7901            state.process_start_time = Some(process.start_time);
7902        })
7903        .unwrap();
7904        record_deliberate_severance(&snapshot, process).unwrap();
7905
7906        drain_child_to_state(
7907            &spec.module_id,
7908            spec.protocol,
7909            &registry,
7910            &snapshot,
7911            &runtime.terminal_ring,
7912            &runtime.spawn_events,
7913            child,
7914            Duration::from_secs(1),
7915            ModuleState::Stopped,
7916            Some(false),
7917        )
7918        .await
7919        .unwrap();
7920
7921        let state = lock_snapshot(&snapshot).unwrap();
7922        assert_eq!(
7923            state.last_exit.as_ref().map(|exit| exit.kind),
7924            Some(ExitKind::DeliberateSeverance)
7925        );
7926        assert_eq!(state.lifetime_restarts, 1);
7927        assert_eq!(state.crash_restarts.len(), 0);
7928        drop(state);
7929        let history = runtime.terminal_ring.lock().unwrap().snapshot();
7930        assert_eq!(
7931            history.entries[0].exit_kind,
7932            subc_control::TerminalExitKind::DeliberateSeverance
7933        );
7934    }
7935
7936    #[tokio::test]
7937    async fn ordinary_drain_reap_does_not_record_a_lifetime_restart() {
7938        let registry = Registry::default();
7939        let supervisor = Supervisor::new(
7940            Arc::new(Registry::default()),
7941            RestartPolicy::new(3, Duration::ZERO),
7942        );
7943        let runtime = supervisor.runtime_config();
7944        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
7945        let spec = ModuleSpec {
7946            module_id: "ordinary-drain".to_string(),
7947            program: fake_aft_stub_path(),
7948            args: Vec::new(),
7949            env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
7950            reserved: false,
7951            reserved_prefixes: Vec::new(),
7952            protocol: ModuleProtocol::Subc,
7953            overlap: Default::default(),
7954        };
7955        let child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
7956
7957        drain_child_to_state(
7958            &spec.module_id,
7959            spec.protocol,
7960            &registry,
7961            &snapshot,
7962            &runtime.terminal_ring,
7963            &runtime.spawn_events,
7964            child,
7965            Duration::from_secs(1),
7966            ModuleState::Stopped,
7967            Some(false),
7968        )
7969        .await
7970        .unwrap();
7971
7972        let state = lock_snapshot(&snapshot).unwrap();
7973        assert_eq!(
7974            state.last_exit.as_ref().map(|exit| exit.kind),
7975            Some(ExitKind::Crash)
7976        );
7977        assert_eq!(state.lifetime_restarts, 0);
7978        assert_eq!(state.crash_restarts.len(), 0);
7979    }
7980
7981    #[test]
7982    fn fatal_connection_teardown_cannot_arm_a_marker_for_a_surviving_process() {
7983        // The server's generic fatal-routing branch only knows that the
7984        // connection failed; it does not know that the daemon deliberately
7985        // initiated a process-killing severance. Keep this seam explicit so a
7986        // future connection error path cannot silently reintroduce the stale
7987        // exemption that mislabels a later genuine crash.
7988        assert!(!include_str!("server.rs")
7989            .contains("router.record_deliberate_connection_severance(ctx.connection_id)"));
7990    }
7991
7992    /// The `route.closed` `drained` value must be the quiescence wait's own
7993    /// measurement (`Ok`), never invented -- except on `Err`, where there is no
7994    /// measurement at all and `false` is the one honest constant. This is the exact
7995    /// logic `begin_forwarding_drain_with` now applies before sending `route.closed`
7996    /// on every return path, including the one that used to return early via `?`
7997    /// with `route.closing` already sent and no `route.closed` ever following.
7998    #[test]
7999    fn drained_after_quiescence_wait_passes_ok_through_and_forces_false_on_err() {
8000        assert!(drained_after_quiescence_wait(&Ok(true)));
8001        assert!(!drained_after_quiescence_wait(&Ok(false)));
8002        assert!(!drained_after_quiescence_wait(&Err(
8003            SuperviseError::StatePoisoned { module_id: None }
8004        )));
8005    }
8006
8007    /// `supervise_loop`'s `wait()`-error arm now calls `record_terminal` like every
8008    /// other exit path does, so a module whose child `wait()` itself errored (e.g.
8009    /// already reaped out-of-band) still leaves a terminal record rather than none
8010    /// at all. Triggering the real `wait()` I/O error from an integration test would
8011    /// need a genuine already-reaped-child race, which is OS-specific and not
8012    /// something this suite attempts elsewhere; this test instead verifies the
8013    /// record produced for that arm end-to-end through the real `TerminalRing`, and
8014    /// the call site itself is verified by inspection to sit in that exact arm.
8015    #[test]
8016    fn wait_error_exit_report_records_a_failed_terminal_with_no_code_or_signal() {
8017        let ring = Arc::new(Mutex::new(TerminalRing::new(
8018            TerminalRingConfig::default(),
8019            0,
8020        )));
8021        record_wait_error_terminal("wait-error", &ring, &super::SpawnEventFeed::default());
8022
8023        let snapshot = ring.lock().unwrap().snapshot();
8024        assert_eq!(snapshot.entries.len(), 1);
8025        let entry = &snapshot.entries[0];
8026        assert_eq!(entry.exit_code, None);
8027        assert_eq!(entry.exit_signal, None);
8028        assert_eq!(entry.disposition, TerminalDisposition::Failed);
8029    }
8030
8031    #[test]
8032    fn wait_error_exit_path_preserves_spawn_event_density() {
8033        let feed = super::SpawnEventFeed::default();
8034        feed.configure_incarnation("wait-error-density".to_string());
8035        feed.emit_spawned("wait-error", 41, 1);
8036        let ring = Arc::new(Mutex::new(TerminalRing::new(
8037            TerminalRingConfig::default(),
8038            0,
8039        )));
8040
8041        record_wait_error_terminal("wait-error", &ring, &feed);
8042        feed.emit_spawned("after-wait-error", 42, 2);
8043
8044        let state = feed.0.lock().unwrap();
8045        let sequences = state
8046            .events
8047            .iter()
8048            .map(|event| event.cursor.seq)
8049            .collect::<Vec<_>>();
8050        assert_eq!(sequences, vec![1, 2, 3]);
8051        assert_eq!(state.events[1].kind, SpawnEventKind::Exited);
8052        assert_eq!(state.events[1].exit_code, None);
8053        assert_eq!(state.events[1].exit_signal, None);
8054    }
8055
8056    /// Pins the report's `kind` too: the wait-error arm treats an unwaitable child
8057    /// as a crash (matching `fail_snapshot`'s `Failed` disposition for this arm),
8058    /// not a clean exit it never actually observed.
8059    #[test]
8060    fn wait_error_exit_report_is_classified_as_a_crash() {
8061        assert_eq!(wait_error_exit_report().kind, ExitKind::Crash);
8062    }
8063}
8064
8065#[cfg(test)]
8066mod health_evidence_tests {
8067    use super::{HealthProbeError, HealthProbeEvidence};
8068    use std::collections::HashSet;
8069
8070    /// The evidential asymmetry, asserted rather than described.
8071    ///
8072    /// Exactly ONE observation is proof a module cannot serve, and the one that
8073    /// fires under CPU starvation is not it. Before the split, all fifteen
8074    /// construction sites collapsed into a single String, so a timeout carried the
8075    /// same weight as a dead lane -- which is how a healthy module was restarted
8076    /// three times in one day.
8077    #[test]
8078    fn only_a_dead_lane_is_proof_of_death() {
8079        assert!(HealthProbeError::lane_dead("gone").is_proof_of_death());
8080        // Three non-proof classes, each for a different reason: silence is
8081        // consistent with health, a bad answer proves the module ALIVE, and a
8082        // daemon-side fault never reached the module at all.
8083        assert!(!HealthProbeError::no_answer("timed out").is_proof_of_death());
8084        assert!(!HealthProbeError::bad_answer("garbage").is_proof_of_death());
8085        assert!(!HealthProbeError::misconfigured("no table").is_proof_of_death());
8086    }
8087
8088    /// Labels must be distinct, or the operator-facing distinction is cosmetic.
8089    ///
8090    /// A shared label renders two different observations identically in the line an
8091    /// operator reads after an unexplained restart -- the exact confusion this
8092    /// change removes.
8093    #[test]
8094    fn every_evidence_class_has_a_distinct_label() {
8095        let labels = [
8096            HealthProbeError::lane_dead("").label(),
8097            HealthProbeError::no_answer("").label(),
8098            HealthProbeError::bad_answer("").label(),
8099            HealthProbeError::misconfigured("").label(),
8100        ];
8101        let unique: HashSet<_> = labels.iter().collect();
8102        assert_eq!(unique.len(), labels.len(), "labels collided: {labels:?}");
8103    }
8104
8105    /// The class is additional information, not a replacement.
8106    ///
8107    /// An operator needs both "this was silence" and the specific text saying how
8108    /// long we waited; a classification that swallowed the message would trade one
8109    /// missing distinction for another.
8110    #[test]
8111    fn classification_preserves_the_original_message() {
8112        let err = HealthProbeError::no_answer("module did not answer within 5s");
8113        assert_eq!(err.to_string(), "module did not answer within 5s");
8114        assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
8115    }
8116}
8117
8118#[cfg(test)]
8119mod health_tombstone_tests {
8120    use std::{path::PathBuf, sync::Arc, time::Duration};
8121
8122    use subc_protocol::{
8123        manifest::Concurrency,
8124        session::{HealthStatus, ModuleControlResponse},
8125    };
8126    use tokio::sync::mpsc;
8127
8128    use super::{
8129        probe_module_health, HealthAction, HealthConfig, HealthProbeEvidence, ModuleProtocol,
8130        ModuleSpec, RestartPolicy, Supervisor, SupervisorRuntimeConfig,
8131    };
8132    use crate::{
8133        control::ControlHandler,
8134        forwarding::{ForwardingTable, ModuleControlRpcCompletion, ModuleControlRpcOutcome},
8135        registry::{ConnectionId, Registry},
8136        router::FrameSink,
8137    };
8138
8139    struct ProbeHarness {
8140        spec: ModuleSpec,
8141        runtime: SupervisorRuntimeConfig,
8142        forwarding: Arc<ForwardingTable>,
8143        module_connection: ConnectionId,
8144        module_rx: mpsc::Receiver<crate::router::OutboundFrame>,
8145        handler: ControlHandler,
8146        module: super::SupervisedModule,
8147    }
8148
8149    fn probe_harness() -> ProbeHarness {
8150        let registry = Arc::new(Registry::default());
8151        let forwarding = Arc::new(ForwardingTable::default());
8152        let supervisor_handle = super::SupervisorHandle::new();
8153        let health = HealthConfig {
8154            cadence: Duration::from_secs(30),
8155            deadline: Duration::from_secs(5),
8156            failure_threshold: 3,
8157            on_degraded: HealthAction::Report,
8158            on_failing: HealthAction::Report,
8159            critical: false,
8160        };
8161        let supervisor = Supervisor::new(Arc::clone(&registry), RestartPolicy::default())
8162            .with_forwarding(Arc::clone(&forwarding))
8163            .with_handle(supervisor_handle.clone())
8164            .with_health_config(health);
8165        let spec = ModuleSpec {
8166            module_id: "late-health-module".to_string(),
8167            program: PathBuf::from("disabled-module"),
8168            args: Vec::new(),
8169            env: Vec::new(),
8170            reserved: false,
8171            reserved_prefixes: Vec::new(),
8172            protocol: ModuleProtocol::Subc,
8173            overlap: Default::default(),
8174        };
8175        let module = supervisor
8176            .supervise_configured(spec.clone(), false)
8177            .unwrap();
8178        let runtime = supervisor.runtime_config();
8179        let handler = ControlHandler::with_forwarding(registry, Arc::clone(&forwarding))
8180            .with_supervisor(supervisor_handle);
8181        let module_connection = ConnectionId::new(700);
8182        let (module_tx, module_rx) = mpsc::channel(8);
8183        forwarding
8184            .register_module_connection(
8185                module_connection,
8186                spec.module_id.clone(),
8187                subc_protocol::PROTOCOL_VERSION,
8188                Concurrency::ModuleManaged,
8189                FrameSink::new(module_tx),
8190            )
8191            .unwrap();
8192
8193        ProbeHarness {
8194            spec,
8195            runtime,
8196            forwarding,
8197            module_connection,
8198            module_rx,
8199            handler,
8200            module,
8201        }
8202    }
8203
8204    async fn finish_after(
8205        harness: &mut ProbeHarness,
8206        stall: Duration,
8207    ) -> ModuleControlRpcCompletion {
8208        assert!(stall > harness.runtime.health.deadline);
8209        let deadline = harness.runtime.health.deadline;
8210        let probe = probe_module_health(&harness.spec.module_id, &harness.runtime, None);
8211        let answer = async {
8212            let frame = harness.module_rx.recv().await.expect("health.check frame");
8213            tokio::time::advance(deadline).await;
8214            tokio::task::yield_now().await;
8215            tokio::time::advance(stall - deadline).await;
8216            harness
8217                .forwarding
8218                .complete_module_control_rpc(
8219                    harness.module_connection,
8220                    frame.header.corr,
8221                    Some("health.check"),
8222                    ModuleControlRpcOutcome::Response(ModuleControlResponse::HealthCheck {
8223                        status: HealthStatus::Ok,
8224                        detail: None,
8225                        metrics: None,
8226                    }),
8227                )
8228                .unwrap()
8229        };
8230        let (probe_result, completion) = tokio::join!(probe, answer);
8231        let err = probe_result.expect_err("probe must miss its deadline");
8232        assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
8233        completion
8234    }
8235
8236    async fn time_out_without_answer(harness: &mut ProbeHarness) {
8237        let deadline = harness.runtime.health.deadline;
8238        let probe = probe_module_health(&harness.spec.module_id, &harness.runtime, None);
8239        let exhaust_deadline = async {
8240            let _frame = harness.module_rx.recv().await.expect("health.check frame");
8241            tokio::time::advance(deadline).await;
8242            tokio::task::yield_now().await;
8243        };
8244        let (probe_result, ()) = tokio::join!(probe, exhaust_deadline);
8245        let err = probe_result.expect_err("probe must miss its deadline");
8246        assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
8247    }
8248
8249    #[tokio::test(start_paused = true)]
8250    async fn late_health_answers_record_start_anchored_latency_for_two_stalls() {
8251        let mut harness = probe_harness();
8252
8253        let first = finish_after(&mut harness, Duration::from_secs(8)).await;
8254        let first_latency = match &first {
8255            ModuleControlRpcCompletion::LateHealthAnswer { latency, .. } => *latency,
8256            other => panic!("late answer was not retained: {other:?}"),
8257        };
8258        assert!(harness.handler.observe_module_control_completion(first));
8259
8260        let second = finish_after(&mut harness, Duration::from_secs(11)).await;
8261        let second_latency = match &second {
8262            ModuleControlRpcCompletion::LateHealthAnswer { latency, .. } => *latency,
8263            other => panic!("late answer was not retained: {other:?}"),
8264        };
8265        assert!(harness.handler.observe_module_control_completion(second));
8266
8267        assert_eq!(first_latency, Duration::from_secs(8));
8268        assert_eq!(
8269            second_latency - first_latency,
8270            Duration::from_secs(3),
8271            "latency must grow linearly with the additional stall"
8272        );
8273        let health = harness.module.status().unwrap().health;
8274        assert_eq!(health.late_answer_count, 2);
8275        assert_eq!(health.last_late_answer_latency_ms, Some(11_000));
8276    }
8277
8278    /// A module that answers every probe late must never march to the kill
8279    /// threshold: the late answer proves it is alive, so it must clear the miss
8280    /// streak the timeout recorded. Without the reset, a CPU-starved module
8281    /// that serves every probe seconds past the deadline accumulates
8282    /// `consecutive_failures` to the threshold and is killed — the exact
8283    /// sequence from the 2026-08-14 aft disable, where the daemon logged
8284    /// "proves the module is alive" five times while counting five misses.
8285    #[tokio::test(start_paused = true)]
8286    async fn late_answer_clears_the_consecutive_failure_streak() {
8287        let mut harness = probe_harness();
8288
8289        // Timeout recorded first: the probe path saw no answer in time.
8290        time_out_without_answer(&mut harness).await;
8291        harness
8292            .module
8293            .record_health_probe_failure_for_test("[no-answer] test miss")
8294            .unwrap();
8295        assert_eq!(
8296            harness.module.status().unwrap().health.consecutive_failures,
8297            1,
8298            "precondition: the miss must be on the streak before the late answer"
8299        );
8300
8301        // The stalled reply then lands: proof of life.
8302        let late = finish_after(&mut harness, Duration::from_secs(9)).await;
8303        assert!(matches!(
8304            late,
8305            ModuleControlRpcCompletion::LateHealthAnswer { .. }
8306        ));
8307        assert!(harness.handler.observe_module_control_completion(late));
8308
8309        let health = harness.module.status().unwrap().health;
8310        assert_eq!(
8311            health.consecutive_failures, 0,
8312            "a late answer is an answer: the streak must reset"
8313        );
8314        assert_eq!(health.late_answer_count, 1);
8315    }
8316
8317    #[tokio::test(start_paused = true)]
8318    async fn repeated_serial_probe_cycles_keep_one_tombstone_per_endpoint() {
8319        let mut harness = probe_harness();
8320
8321        for _ in 0..20 {
8322            time_out_without_answer(&mut harness).await;
8323            assert_eq!(
8324                harness.forwarding.health_probe_tombstone_count().unwrap(),
8325                1
8326            );
8327        }
8328    }
8329}
8330
8331#[cfg(test)]
8332mod child_env_tests {
8333    use super::{
8334        apply_child_env, apply_spawn_role, apply_wire_spawn_args, ModuleProtocol, ModuleSpec,
8335        SpawnRole, SupervisorHandle, SPAWN_ROLE_SWAP_CANDIDATE, SUBC_ARG, SUBC_LAUNCH_NONCE_ENV,
8336        SUBC_MODULE_ID_ENV, SUBC_SPAWN_ROLE_ENV,
8337    };
8338    use std::{ffi::OsStr, path::PathBuf};
8339    use tokio::process::Command;
8340
8341    fn spec(env: Vec<(String, String)>) -> ModuleSpec {
8342        ModuleSpec {
8343            module_id: "env-plan".to_string(),
8344            program: PathBuf::from("/nonexistent"),
8345            args: Vec::new(),
8346            env,
8347            reserved: false,
8348            reserved_prefixes: Vec::new(),
8349            protocol: ModuleProtocol::Subc,
8350            overlap: Default::default(),
8351        }
8352    }
8353
8354    /// Ambient `CK_LOG` is REMOVED for an unconfigured module, and a configured
8355    /// one still gets its own.
8356    ///
8357    /// This is the narrow goal `env_clear()` was reached for, and the reason the
8358    /// fix is `env_remove` rather than deleting the line: an operator's ambient
8359    /// filter silently becoming an unconfigured module's log level is a real
8360    /// defect, just a much smaller one than clearing the environment.
8361    ///
8362    /// Asserted on the command plan rather than a spawned child because proving
8363    /// the ABSENCE of an inherited variable needs the parent's environment
8364    /// mutated, and `forbid(unsafe_code)` refuses that. `get_envs()` reports a
8365    /// removal as `(key, None)`, which is exactly the distinction wanted: not
8366    /// "absent because nobody set it" but "explicitly unset for the child".
8367    #[test]
8368    fn ambient_ck_log_is_removed_and_a_configured_one_survives() {
8369        let mut command = Command::new("/nonexistent");
8370        apply_child_env(&mut command, &spec(Vec::new()));
8371        let removed = command
8372            .as_std()
8373            .get_envs()
8374            .any(|(key, value)| key == OsStr::new("CK_LOG") && value.is_none());
8375        assert!(
8376            removed,
8377            "ambient CK_LOG must be explicitly removed for an unconfigured module"
8378        );
8379
8380        let mut configured = Command::new("/nonexistent");
8381        apply_child_env(
8382            &mut configured,
8383            &spec(vec![("CK_LOG".to_string(), "debug".to_string())]),
8384        );
8385        let effective = configured
8386            .as_std()
8387            .get_envs()
8388            .filter(|(key, _)| *key == OsStr::new("CK_LOG"))
8389            .last()
8390            .map(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()));
8391        assert_eq!(
8392            effective,
8393            Some(Some("debug".to_string())),
8394            "a module's configured CK_LOG must survive the ambient removal"
8395        );
8396    }
8397
8398    /// A `protocol: "none"` spawn carries NO `--subc` argument and NO launch
8399    /// nonce; a subc-wire spawn carries both. Asserted on the command plan for
8400    /// the same reason as the CK_LOG test above.
8401    ///
8402    /// The argument is the load-bearing half: a stock binary exits on an
8403    /// unknown flag before it listens, so with `--subc` appended the mode
8404    /// could not supervise the one process it exists for. Found by the first
8405    /// conformance run (nats-server: `flag provided but not defined: -subc`).
8406    #[test]
8407    fn protocol_none_spawn_carries_no_subc_argument_and_no_nonce() {
8408        let connection_file = std::path::Path::new("/run/subc-connection.json");
8409        let handle = SupervisorHandle::new();
8410
8411        let mut none_spec = spec(Vec::new());
8412        none_spec.protocol = ModuleProtocol::None;
8413        let mut none = Command::new("/nonexistent");
8414        apply_wire_spawn_args(&mut none, &none_spec, Some(connection_file), Some(&handle))
8415            .expect("protocol-none spawn args apply");
8416        let none_args: Vec<String> = none
8417            .as_std()
8418            .get_args()
8419            .map(|a| a.to_string_lossy().into_owned())
8420            .collect();
8421        assert!(
8422            !none_args.iter().any(|a| a == SUBC_ARG),
8423            "protocol:none argv must not carry --subc; got {none_args:?}"
8424        );
8425        let none_has_nonce = none
8426            .as_std()
8427            .get_envs()
8428            .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some());
8429        assert!(
8430            !none_has_nonce,
8431            "protocol:none spawn must not receive a launch nonce"
8432        );
8433        let none_has_module_id = none
8434            .as_std()
8435            .get_envs()
8436            .any(|(key, value)| key == OsStr::new(SUBC_MODULE_ID_ENV) && value.is_some());
8437        assert!(
8438            none_has_module_id,
8439            "SUBC_MODULE_ID is inert and stays on every path"
8440        );
8441        assert!(
8442            handle.spawn_nonce(&none_spec.module_id).is_none(),
8443            "no nonce record for a process that will never present one"
8444        );
8445
8446        // Control: the subc-wire path is unchanged by the branch above.
8447        let wire_spec = spec(Vec::new());
8448        let mut wire = Command::new("/nonexistent");
8449        apply_wire_spawn_args(&mut wire, &wire_spec, Some(connection_file), Some(&handle))
8450            .expect("subc-wire spawn args apply");
8451        let wire_args: Vec<String> = wire
8452            .as_std()
8453            .get_args()
8454            .map(|a| a.to_string_lossy().into_owned())
8455            .collect();
8456        assert_eq!(
8457            wire_args,
8458            vec![
8459                SUBC_ARG.to_string(),
8460                connection_file.to_string_lossy().into_owned()
8461            ],
8462            "a subc-wire spawn still carries --subc <path>"
8463        );
8464        assert!(wire
8465            .as_std()
8466            .get_envs()
8467            .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some()));
8468        assert!(handle.spawn_nonce(&wire_spec.module_id).is_some());
8469    }
8470
8471    /// A plain spawn EXPLICITLY REMOVES the spawn role, even when the module's
8472    /// spec tries to set it; only a swap candidate carries it.
8473    ///
8474    /// "Set it only on candidates" is not enough, because spawn applies the
8475    /// spec's env verbatim and the daemon's own environment is inherited: either
8476    /// could hand a plain restart the swap role, and a module reading it would
8477    /// warm on its long swap budget while callers wait. Asserted as an explicit
8478    /// removal (`(key, None)`), not mere absence, for the reason the `CK_LOG`
8479    /// test above gives.
8480    #[test]
8481    fn plain_spawn_removes_the_spawn_role_even_when_the_spec_sets_it() {
8482        let role = |command: &Command| {
8483            command
8484                .as_std()
8485                .get_envs()
8486                .filter(|(key, _)| *key == OsStr::new(SUBC_SPAWN_ROLE_ENV))
8487                .last()
8488                .map(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()))
8489        };
8490        let forged = spec(vec![(
8491            SUBC_SPAWN_ROLE_ENV.to_string(),
8492            SPAWN_ROLE_SWAP_CANDIDATE.to_string(),
8493        )]);
8494
8495        let mut plain = Command::new("/nonexistent");
8496        apply_child_env(&mut plain, &forged);
8497        apply_spawn_role(&mut plain, SpawnRole::Plain);
8498        assert_eq!(
8499            role(&plain),
8500            Some(None),
8501            "a plain spawn must remove SUBC_SPAWN_ROLE, whatever the spec says"
8502        );
8503
8504        let mut candidate = Command::new("/nonexistent");
8505        apply_child_env(&mut candidate, &spec(Vec::new()));
8506        apply_spawn_role(&mut candidate, SpawnRole::SwapCandidate);
8507        assert_eq!(
8508            role(&candidate),
8509            Some(Some(SPAWN_ROLE_SWAP_CANDIDATE.to_string()))
8510        );
8511    }
8512
8513    /// Daemon-private capture retention keys never reach the child.
8514    ///
8515    /// cortexkit-log exposes retention as a Rust struct with no environment
8516    /// names, so these entries are supervisor metadata. Passing them through
8517    /// would invent a public child-process contract by accident.
8518    #[test]
8519    fn daemon_private_capture_keys_are_not_passed_to_the_child() {
8520        let mut command = Command::new("/nonexistent");
8521        apply_child_env(
8522            &mut command,
8523            &spec(vec![
8524                (super::CAPTURE_KEEP_ENV.to_string(), "5".to_string()),
8525                ("KEPT".to_string(), "yes".to_string()),
8526            ]),
8527        );
8528        let keys: Vec<String> = command
8529            .as_std()
8530            .get_envs()
8531            .filter(|(_, value)| value.is_some())
8532            .map(|(key, _)| key.to_string_lossy().into_owned())
8533            .collect();
8534        assert!(keys.contains(&"KEPT".to_string()), "got {keys:?}");
8535        assert!(
8536            !keys.contains(&super::CAPTURE_KEEP_ENV.to_string()),
8537            "daemon-private capture key leaked to the child: {keys:?}"
8538        );
8539    }
8540}
8541
8542#[cfg(test)]
8543mod jitter_tests {
8544    use super::jittered_health_delay;
8545    use std::{collections::HashSet, time::Duration};
8546
8547    /// Module ids drawn from a real fleet, so the dispersal claim is about names
8548    /// that actually occur rather than invented ones.
8549    ///
8550    /// This is a SAMPLE, not a registry: the property under test is that distinct
8551    /// ids disperse, which holds for any set of distinct strings. Several entries
8552    /// are already historical (modules get renamed), and that costs nothing here --
8553    /// but it means a reader must not mistake this for the live module set, and a
8554    /// rename sweep will match it without there being anything to change.
8555    const FLEET: [&str; 14] = [
8556        "aft",
8557        "alfonso-core",
8558        "magic-context",
8559        "broca",
8560        "thalamus",
8561        "quota",
8562        "engram",
8563        "plexus",
8564        "cerebellum",
8565        "astrocyte",
8566        "synapse",
8567        "subc-mcp",
8568        "cortexkit-credentials",
8569        "subc-federation",
8570    ];
8571
8572    /// Probes must not converge after a fleet-wide restart.
8573    ///
8574    /// This is the property the jitter exists for: every module reconnects at
8575    /// once, and without dispersal all fourteen would then probe on the same
8576    /// tick forever. Nothing failed visibly when this went untested -- a
8577    /// convergent fleet still probes correctly, just in a burst, so the symptom
8578    /// is a periodic load spike that looks like whatever else is running.
8579    #[test]
8580    fn probe_delays_disperse_across_the_fleet() {
8581        let cadence = Duration::from_secs(30);
8582        let delays: HashSet<Duration> = FLEET
8583            .iter()
8584            .map(|id| jittered_health_delay(id, 0, cadence))
8585            .collect();
8586        assert_eq!(
8587            delays.len(),
8588            FLEET.len(),
8589            "every supervised module must land on its own probe offset"
8590        );
8591    }
8592
8593    /// The offset may only ever DELAY a probe, never bring it forward.
8594    ///
8595    /// A delay below the cadence would probe a module more often than
8596    /// configured, which is the opposite of what an operator asked for and
8597    /// would tighten the failure budget without anyone changing it.
8598    #[test]
8599    fn jitter_only_delays_and_stays_within_one_tenth_of_cadence() {
8600        let cadence = Duration::from_secs(30);
8601        let span = cadence / 10;
8602        for id in FLEET {
8603            for probe_index in 0..8 {
8604                let delay = jittered_health_delay(id, probe_index, cadence);
8605                assert!(
8606                    delay >= cadence,
8607                    "{id}#{probe_index}: jitter must not shorten the cadence"
8608                );
8609                assert!(
8610                    delay < cadence + span,
8611                    "{id}#{probe_index}: jitter must stay inside one tenth of the cadence"
8612                );
8613            }
8614        }
8615    }
8616
8617    /// A module keeps its offset across daemon restarts.
8618    ///
8619    /// The delay is derived rather than randomised precisely so a restart does
8620    /// not re-roll every module into a fresh chance of collision. A random
8621    /// source would satisfy the dispersal test above and quietly lose this.
8622    #[test]
8623    fn a_module_offset_is_stable_across_restarts() {
8624        let cadence = Duration::from_secs(30);
8625        for id in FLEET {
8626            assert_eq!(
8627                jittered_health_delay(id, 0, cadence),
8628                jittered_health_delay(id, 0, cadence),
8629                "{id}: the same module and probe index must produce the same offset"
8630            );
8631        }
8632    }
8633
8634    /// A zero cadence disables probing rather than producing a busy loop.
8635    #[test]
8636    fn zero_cadence_yields_zero_delay() {
8637        assert_eq!(
8638            jittered_health_delay("aft", 0, Duration::ZERO),
8639            Duration::ZERO
8640        );
8641    }
8642}
8643
8644#[cfg(all(test, target_os = "linux"))]
8645mod cgroup_placement_tests {
8646    use super::{
8647        apply_cgroup_placement, remove_module_cgroup, ModuleProtocol, ModuleSpec, SuperviseError,
8648        SupervisedChild,
8649    };
8650    use crate::{
8651        stderr_tail::{StderrRing, StderrTailConfig},
8652        test_support::TestTempDir,
8653    };
8654    use std::{
8655        fs, io,
8656        path::{Path, PathBuf},
8657        sync::{Arc, Mutex},
8658    };
8659    use tokio::process::Command;
8660
8661    #[test]
8662    fn failed_parent_cgroup_open_is_a_cgroup_supervision_error() {
8663        let path = Path::new("/definitely-missing-subc-cgroup");
8664        let mut command = Command::new("true");
8665        let error = apply_cgroup_placement(
8666            &mut command,
8667            &ModuleSpec {
8668                module_id: "broken-cgroup".to_string(),
8669                program: PathBuf::from("true"),
8670                args: Vec::new(),
8671                env: Vec::new(),
8672                reserved: false,
8673                reserved_prefixes: Vec::new(),
8674                protocol: ModuleProtocol::Subc,
8675                overlap: Default::default(),
8676            },
8677            path,
8678        )
8679        .expect_err("a parent cgroup open failure must reject the supervised spawn");
8680        let reason = error.to_string();
8681
8682        assert!(
8683            matches!(error, SuperviseError::Cgroup { .. }),
8684            "parent cgroup open must be reported as a cgroup supervision error: {reason}"
8685        );
8686        assert!(
8687            reason.contains("/definitely-missing-subc-cgroup/cgroup.procs"),
8688            "parent cgroup open failure must name cgroup.procs: {reason}"
8689        );
8690    }
8691
8692    #[tokio::test]
8693    async fn reaping_a_child_removes_its_empty_module_cgroup() {
8694        let root = TestTempDir::new("supervisor-reap-cgroup");
8695        fs::write(root.join("cgroup.procs"), b"").expect("write scratch cgroup marker");
8696        let placement = subc_cgroup::prepare_at(&root)
8697            .expect("prepare scratch cgroup root")
8698            .expect("scratch root has a cgroup.procs marker");
8699        let module_id = "reaped-module";
8700        let module = placement
8701            .module_path(module_id)
8702            .expect("create scratch module cgroup");
8703        let child = Command::new("true")
8704            .spawn()
8705            .expect("spawn short-lived child");
8706        let pid = child.id().expect("spawned child has pid");
8707        let mut child = SupervisedChild {
8708            child,
8709            module_id: module_id.to_string(),
8710            cgroup_placement: Some(placement),
8711            stdout_pump: None,
8712            stderr_pump: None,
8713            stderr_ring: Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default()))),
8714            spawned_at_ms: 0,
8715            spawned_from: PathBuf::from("true"),
8716            spawned_file_identity: None,
8717            process_start_time: None,
8718            process_identity: None,
8719            pid,
8720            roster_guard: None,
8721        };
8722
8723        child.wait().await.expect("reap short-lived child");
8724
8725        assert!(
8726            !module.exists(),
8727            "reaping the supervised child must remove its empty cgroup"
8728        );
8729    }
8730
8731    #[test]
8732    fn non_empty_cgroup_removal_is_reported_without_blocking_teardown() {
8733        let root = TestTempDir::new("supervisor-non-empty-cgroup");
8734        fs::write(root.join("cgroup.procs"), b"").expect("write scratch cgroup marker");
8735        let placement = subc_cgroup::prepare_at(&root)
8736            .expect("prepare scratch cgroup root")
8737            .expect("scratch root has a cgroup.procs marker");
8738        let module = placement
8739            .module_path("surviving-module")
8740            .expect("create scratch module cgroup");
8741        fs::write(module.join("surviving-process"), b"still present")
8742            .expect("make scratch cgroup non-empty");
8743        let (logs, _guard) = crate::router::test_log::log_capture(tracing::Level::WARN);
8744
8745        remove_module_cgroup(&placement, "surviving-module");
8746
8747        let logs = crate::router::test_log::captured_logs(&logs);
8748        assert!(
8749            module.exists(),
8750            "failed removal must leave the cgroup intact"
8751        );
8752        assert!(
8753            logs.contains("could not remove module cgroup after process exit; continuing teardown")
8754                && logs.contains("surviving-module"),
8755            "best-effort removal must report the failure without returning it: {logs}"
8756        );
8757    }
8758
8759    #[test]
8760    fn cgroup_pre_exec_spawn_failure_names_the_cgroup_path() {
8761        let cgroup_path = PathBuf::from("/sys/fs/cgroup/subc-modules/broken-module");
8762        let reason = SuperviseError::Spawn {
8763            program: PathBuf::from("/bin/true"),
8764            source: io::Error::from_raw_os_error(13),
8765            cgroup_path: Some(cgroup_path.clone()),
8766        }
8767        .to_string();
8768
8769        assert!(
8770            reason.contains(&cgroup_path.display().to_string()),
8771            "a pre_exec spawn failure must name the cgroup path: {reason}"
8772        );
8773    }
8774}