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