Skip to main content

subc_daemon/
supervise.rs

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