Skip to main content

subc_daemon/
supervise.rs

1use std::{
2    collections::{HashMap, HashSet, 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, Principal, 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::{ExecutableIdentityProbe, SpawnedFileIdentity},
43    registry::{ConnectionId, RegistrationEndReason, 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#[cfg(not(windows))]
53use crate::provenance::spawned_file_identity;
54
55#[path = "supervise_swap.rs"]
56mod swap;
57
58#[path = "supervise_activity.rs"]
59mod activity;
60
61/// Command-line flag used by supervised modules to find subc.
62///
63/// subc launches module-mode children as `<module> --subc <connection-file-path>`.
64/// The path points at the TCP+key connection file; it is not an ambient signal and
65/// is never inherited by standalone children.
66pub const SUBC_ARG: &str = "--subc";
67
68const DEFAULT_MAX_RESTARTS: u32 = 3;
69const DEFAULT_BACKOFF: Duration = Duration::from_millis(100);
70const DEFAULT_MAX_BACKOFF: Duration = Duration::from_secs(30);
71/// The span `DEFAULT_MAX_RESTARTS` is counted over. Ten minutes is long enough
72/// to contain a real crash loop (which respawns in seconds) and short enough
73/// that unrelated crashes hours apart never accumulate into a permanent stop.
74const DEFAULT_RESTART_WINDOW: Duration = Duration::from_secs(600);
75/// How long a drain waits for already-dispatched requests to finalize before
76/// the child is torn down. Sized for TOOL-SCALE work (bash, inspect, builds),
77/// not RPC-scale: the original 2s value silently cut nearly every real tool
78/// call at the fence, making the wait-for-finalize design decorative for the
79/// workloads it existed for. Quiescence short-circuits, so an idle module
80/// restarts immediately regardless of this value; the budget is spent only
81/// when a genuine in-flight request is worth finishing. Per-module override:
82/// `drain_timeout_ms` in subc.jsonc; per-restart override: the operator's
83/// `supervisor.restart{drain_timeout_ms}` (0 = cut now, for wedge bounces
84/// where a stuck request will never settle).
85pub const DEFAULT_DRAIN_TIMEOUT: Duration = Duration::from_secs(30);
86const REGISTRY_RELEASE_TIMEOUT: Duration = Duration::from_secs(1);
87const REGISTRY_RELEASE_POLL: Duration = Duration::from_millis(10);
88/// How long a restart waits for the exited process's output readers.
89///
90/// The restart does not depend on either output reader finishing. A stderr
91/// reader still running at this bound is left running, and its later output goes
92/// into the exited process's own section of the stderr ring (see
93/// `StderrRing::push_line_from`), ending naturally at EOF on its pipe. So the
94/// bound no longer decides whether a crash's last lines are kept: under load
95/// the reader may simply not have been scheduled yet, and cutting it there
96/// discarded exactly the lines that explained the crash.
97///
98/// What the bound still decides is when the tail starts reporting
99/// `Incomplete`: a pipe open past it usually means a descendant of the exited
100/// process still holds it, and the tail cannot claim to be whole until that
101/// pipe closes. That is also the only case in which the wait costs the restart
102/// anything, because a pipe with no other holder reaches EOF when the process
103/// exits. Under load a slow reader can show `Incomplete` briefly; it returns to
104/// `Captured` at EOF with nothing lost.
105///
106/// The stdout reader carries no ring, only the capture file. It also continues
107/// to EOF after this bound: child exit closes the writer but does not mean the
108/// reader has consumed all buffered bytes. Late output from either pipe can
109/// therefore reach the shared capture file after the successor starts.
110const STDERR_PUMP_DRAIN_TIMEOUT: Duration = Duration::from_millis(250);
111/// Maximum number of supervised process spawn/exit facts retained per daemon incarnation.
112pub const SPAWN_EVENT_RING_CAPACITY: usize = 4096;
113const SPAWN_SUBSCRIBER_BUFFER: usize = SPAWN_EVENT_RING_CAPACITY + 1;
114/// Terminal Error code for a `supervisor.spawn_subscribe` stream the daemon
115/// dropped because the subscriber stopped draining its frames. The detail's
116/// `first_undelivered_cursor` names the first event it did not receive; the
117/// client resubscribes from the last cursor it did receive.
118pub(crate) const SPAWN_SUBSCRIBER_LAGGED_CODE: &str = "spawn_subscriber_lagged";
119
120#[cfg(target_os = "macos")]
121struct PrivacyExec {
122    reader: tokio::io::unix::AsyncFd<std::io::PipeReader>,
123    deadline: tokio::time::Instant,
124    expected: Option<subc_os::FileIdentity>,
125    trampoline: Option<subc_os::FileIdentity>,
126    script: bool,
127    module_id: String,
128}
129
130#[cfg(all(test, target_os = "macos"))]
131pub(crate) fn test_privacy_trampoline() -> PathBuf {
132    // Cargo's unit-test executable lives in <profile>/deps; its fixture bin
133    // lives beside that directory. This honors custom CARGO_TARGET_DIR too.
134    let path = std::env::current_exe()
135        .unwrap()
136        .parent()
137        .unwrap()
138        .parent()
139        .unwrap()
140        .join("privacy-trampoline-fixture");
141    // Without the fixture every macOS spawn is refused, and the tests that
142    // spawn fail later as a module in state Failed, which names the wrong
143    // cause. `cargo test -p subc-daemon --lib` alone does not build it.
144    assert!(
145        path.exists(),
146        "privacy-trampoline-fixture not built at {}: run `cargo build -p subc-daemon \
147         --bins --features test-support` or `cargo test -p subc-daemon` first",
148        path.display()
149    );
150    path
151}
152
153#[cfg(target_os = "macos")]
154fn probe_privacy_trampoline(path: &std::path::Path) -> Result<(), String> {
155    use std::io::Read;
156    let mut probe = std::process::Command::new(path)
157        .args(["__disclaim-exec", "--probe"])
158        .stdin(Stdio::null())
159        .stdout(Stdio::piped())
160        .stderr(Stdio::piped())
161        .spawn()
162        .map_err(|error| {
163            format!(
164                "privacy trampoline probe failed for {}: {error}",
165                path.display()
166            )
167        })?;
168    let deadline = std::time::Instant::now() + Duration::from_secs(5);
169    let status = loop {
170        match probe.try_wait() {
171            Ok(Some(status)) => break status,
172            Ok(None) if std::time::Instant::now() < deadline => {
173                std::thread::sleep(Duration::from_millis(5))
174            }
175            result => {
176                let _ = probe.kill();
177                let _ = probe.wait();
178                return Err(format!(
179                    "privacy trampoline probe failed or timed out for {}: {result:?}",
180                    path.display()
181                ));
182            }
183        }
184    };
185    let mut answer = String::new();
186    if let Some(stdout) = probe.stdout.take() {
187        let _ = stdout.take(256).read_to_string(&mut answer);
188    }
189    if status.success() && answer.trim() == subc_os::privacy_identity::TRAMPOLINE_PROBE {
190        return Ok(());
191    }
192    let mut diagnostic = String::new();
193    if let Some(stderr) = probe.stderr.take() {
194        let _ = stderr.take(1024).read_to_string(&mut diagnostic);
195    }
196    let cause = diagnostic
197        .trim()
198        .strip_prefix("ck-subc: own privacy identity refused: ")
199        .unwrap_or("binary does not implement the privacy trampoline protocol");
200    Err(format!(
201        "{cause}: probe of {} exited {status}",
202        path.display()
203    ))
204}
205
206#[cfg(target_os = "macos")]
207fn privacy_command(
208    spec: &ModuleSpec,
209    roster: &ChildRoster,
210) -> Result<
211    (
212        Command,
213        Option<PrivacyExec>,
214        subc_os::privacy_identity::ExecAcknowledgement,
215    ),
216    SuperviseError,
217> {
218    let failure = |cause: String| {
219        warn!(module_id = %spec.module_id, %cause, "privacy identity trampoline refused module spawn");
220        SuperviseError::Spawn {
221            program: spec.program.clone(),
222            source: io::Error::other(cause),
223            cgroup_path: None,
224        }
225    };
226    let trampoline = roster.privacy_trampoline().map_err(failure)?;
227    // Resolve PATH with the same environment the Command will receive. For
228    // scripts retain the existing orphan-identity rule: the kernel chooses
229    // the interpreter, and its observed image is the one recorded. Do not
230    // duplicate the kernel's shebang/PATH interpreter resolution in Rust.
231    let program = if spec.program.components().count() == 1 && !spec.program.is_absolute() {
232        let path = spec
233            .env
234            .iter()
235            .find(|(key, _)| key == "PATH")
236            .map(|(_, value)| std::ffi::OsString::from(value))
237            .or_else(|| std::env::var_os("PATH"))
238            .unwrap_or_else(|| "/usr/bin:/bin".into());
239        std::env::split_paths(&path)
240            .map(|dir| dir.join(&spec.program))
241            .find(|path| path.is_file())
242            .unwrap_or_else(|| spec.program.clone())
243    } else {
244        spec.program.clone()
245    };
246    let expected = subc_os::file_identity(&program);
247    let trampoline_image = subc_os::file_identity(&trampoline);
248    let script = {
249        use std::io::Read;
250        let mut prefix = [0u8; 2];
251        std::fs::File::open(&program)
252            .is_ok_and(|mut file| file.read_exact(&mut prefix).is_ok() && &prefix == b"#!")
253    };
254    if expected.is_none() || expected == subc_os::file_identity(&trampoline) {
255        return Err(failure(
256            "privacy identity module executable is missing or is the trampoline itself".to_string(),
257        ));
258    }
259    let (reader, ack) = subc_os::privacy_identity::ExecAcknowledgement::pipe()
260        .map_err(|error| failure(error.to_string()))?;
261    let reader =
262        tokio::io::unix::AsyncFd::new(reader).map_err(|error| failure(error.to_string()))?;
263    let mut command = Command::new(&trampoline);
264    command
265        .arg("__disclaim-exec")
266        .arg(ack.fd().to_string())
267        .arg(&program);
268    ack.install(command.as_std_mut());
269    Ok((
270        command,
271        Some(PrivacyExec {
272            reader,
273            deadline: tokio::time::Instant::now() + Duration::from_secs(5),
274            expected,
275            trampoline: trampoline_image,
276            script,
277            module_id: spec.module_id.clone(),
278        }),
279        ack,
280    ))
281}
282
283struct SupervisedChild {
284    child: Child,
285    #[cfg(windows)]
286    nonce_delivery: Option<subc_os::LaunchNoncePipeDelivery>,
287    #[cfg(target_os = "macos")]
288    privacy_exec: Option<PrivacyExec>,
289    /// Set once this launch's exec acknowledgement confirms the module image.
290    /// On macOS the pid first runs the `ck-subc` launch trampoline (see
291    /// `subc_os::privacy_identity`), which then replaces itself with the
292    /// module. The supervisor owns and can kill that pid from spawn, but
293    /// status readers report it only after this latch is set, so nothing
294    /// reports the trampoline's image as the module's.
295    #[cfg(target_os = "macos")]
296    report_ready: Arc<OnceLock<()>>,
297    /// Refusal before the module image was accepted, retained for terminal records.
298    spawn_failure: Option<String>,
299    /// The protocol this process was launched with. A reload can store a new
300    /// launch spec with a different protocol, but that takes effect only at the
301    /// next spawn, so this process keeps being handled by the protocol it
302    /// actually speaks.
303    protocol: ModuleProtocol,
304    /// This process's cgroup name: a bounded module/slot label followed by a
305    /// spawn suffix unique to this process (when cgroup placement is on). A
306    /// retired process in a slot may still be draining when a later one is
307    /// spawned into that slot, so the suffix keeps the later process out of
308    /// the retired one's cgroup, which is the domain a kill applies to.
309    #[cfg(target_os = "linux")]
310    module_id: String,
311    #[cfg(target_os = "linux")]
312    cgroup_placement: Option<subc_cgroup::Placement>,
313    /// The job that contains this child and every process it spawns (issue #109).
314    ///
315    /// Dropping this handle is what reaps a surviving tree when no supervisor
316    /// code runs — a daemon crash — because the job carries
317    /// `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`.
318    ///
319    /// That limit is not crash-only, and the difference is worth knowing: a
320    /// Windows daemon *stop* is `taskkill` or the scheduler's `/End` — the
321    /// SIGTERM handler is `#[cfg(unix)]` — so the daemon dies with no stop
322    /// notice and the kernel closes the job handle, `TerminateProcess`ing every
323    /// module at once. Before this change they survived that, saw EOF on the
324    /// control socket, and ran their own teardown; Unix keeps that path
325    /// deliberately, so a module can seal a WAL or close a capture rather than
326    /// be killed mid-write. So this trades graceful teardown on every Windows
327    /// daemon stop for containment on a crash, which is the right way round
328    /// today: orphaned GPU workers are a reported, recurring problem, and the
329    /// modules that write most heavily do not run on Windows.
330    ///
331    /// The fix is a real Windows stop path — the daemon draining before it
332    /// exits, the twin of the Unix SIGTERM handler. Once it exists, this limit
333    /// reaches only what the drain left behind, which is what it should reach.
334    #[cfg(windows)]
335    job: Option<subc_jobobject::JobObject>,
336    stdout_pump: Option<JoinHandle<()>>,
337    stderr_pump: Option<StderrPump>,
338    stderr_ring: Arc<Mutex<StderrRing>>,
339    spawned_at_ms: u64,
340    spawned_from: PathBuf,
341    spawned_file_identity: Option<SpawnedFileIdentity>,
342    process_start_time: Option<u64>,
343    process_identity: Option<ProcessIdentity>,
344    pid: u32,
345    /// This process's entry in the daemon's child roster, released when the
346    /// process is reaped or this handle is dropped.
347    roster_guard: Option<crate::child_roster::RosterGuard>,
348}
349
350impl SupervisedChild {
351    fn id(&self) -> Option<u32> {
352        Some(self.pid)
353    }
354
355    fn process_identity(&self) -> Option<ProcessIdentity> {
356        self.process_identity
357    }
358
359    async fn wait(&mut self) -> io::Result<ExitStatus> {
360        #[cfg(target_os = "macos")]
361        self.confirm_privacy_exec().await;
362        // The roster entry is NOT released here. A daemon shutdown waits for the
363        // roster to empty and then exits the process, so releasing at the reap
364        // let it exit before the exit handler wrote this child's terminal record
365        // (the stderr drain and snapshot update sit in between), and the
366        // shutdown's own `daemon_shutdown` record was intermittently lost. The
367        // caller releases it after recording the exit (`release_roster`), and
368        // dropping the handle releases it too.
369        let result = self.child.wait().await;
370        #[cfg(target_os = "linux")]
371        if result.is_ok() {
372            if let Some(placement) = self.cgroup_placement.as_ref() {
373                cleanup_reaped_cgroup(placement, &self.module_id).await;
374                // Keep ownership while awaiting kernel population changes: a
375                // drain timeout may cancel this wait and then escalate/reap.
376                self.cgroup_placement = None;
377            }
378        }
379        result
380    }
381
382    #[cfg(target_os = "macos")]
383    async fn confirm_privacy_exec(&mut self) {
384        let Some(pending) = &mut self.privacy_exec else {
385            return;
386        };
387        let result = tokio::time::timeout_at(pending.deadline, async {
388            let mut record = Vec::new();
389            loop {
390                let mut ready = pending.reader.readable().await?;
391                let read = ready.try_io(|reader| {
392                    use std::io::Read;
393                    let mut reader = reader.get_ref();
394                    let mut buffer = [0u8; 256];
395                    reader.read(&mut buffer).map(|count| (count, buffer))
396                });
397                match read {
398                    Ok(Ok((0, _))) => return Ok::<_, io::Error>(record),
399                    Ok(Ok((count, buffer))) => {
400                        if record.len() + count > 1024 {
401                            return Err(io::Error::other(
402                                "privacy exec refusal record is too long",
403                            ));
404                        }
405                        record.extend_from_slice(&buffer[..count]);
406                    }
407                    Ok(Err(error)) => return Err(error),
408                    Err(_) => continue,
409                }
410            }
411        })
412        .await;
413        // Keep the reader in self across await: select cancellation must not
414        // discard the handshake or reset its original five-second deadline.
415        let pending = self.privacy_exec.as_ref().expect("pending exec");
416        let cause = match result {
417            Err(_) => Some("privacy identity trampoline did not exec within 5s".to_string()),
418            Ok(Err(error)) => Some(format!(
419                "privacy identity exec acknowledgement failed: {error}"
420            )),
421            Ok(Ok(record)) if !record.is_empty() => Some(
422                std::str::from_utf8(&record)
423                    .ok()
424                    .and_then(|record| {
425                        record
426                            .trim()
427                            .strip_prefix(subc_os::privacy_identity::EXEC_REFUSAL_TAG)
428                    })
429                    .filter(|cause| !cause.is_empty())
430                    .unwrap_or("invalid privacy exec refusal record")
431                    .to_string(),
432            ),
433            Ok(Ok(_)) => match self.child.try_wait() {
434                // Empty EOF is the exec acknowledgement. A real module may exit
435                // immediately, including with a reserved trampoline status; no
436                // image is admitted, and its ordinary exit contract stays intact.
437                Ok(Some(_status)) => None,
438                Err(error) => Some(format!("privacy identity trampoline wait failed: {error}")),
439                Ok(None) => {
440                    let image = observe_spawned_image(self.pid);
441                    if let Some(image) = image.filter(|image| {
442                        image.executable.is_some()
443                            && image.executable != pending.trampoline
444                            && (image.executable == pending.expected || pending.script)
445                    }) {
446                        if let Some(guard) = &self.roster_guard {
447                            guard.confirm_executable(image);
448                        }
449                        let _ = self.report_ready.set(());
450                        info!(module_id = %pending.module_id, pid = self.pid,
451                            "module spawned with own privacy identity (responsibility disclaimed)");
452                        None
453                    } else if image.is_none()
454                        || image.is_some_and(|image| image.executable.is_none())
455                    {
456                        // A process can exit between try_wait and the kernel
457                        // image read. Empty EOF already acknowledged exec, so
458                        // preserve that module's ordinary exit rather than
459                        // mislabel a disappearing image as trampoline refusal.
460                        // Keep pending in self across await so cancellation does
461                        // not discard validation or reset its original deadline.
462                        match tokio::time::timeout_at(pending.deadline, self.child.wait()).await {
463                            Ok(Ok(_status)) => None,
464                            Ok(Err(error)) => Some(format!("privacy identity trampoline wait failed: {error}")),
465                            Err(_) => Some("privacy identity module executable remained unreadable until the 5s exec deadline".to_string()),
466                        }
467                    } else {
468                        Some("privacy identity trampoline executable mismatch: expected the module program, not ck-subc".to_string())
469                    }
470                }
471            },
472        };
473        let pending = self.privacy_exec.take().expect("pending exec");
474        if let Some(cause) = cause {
475            warn!(module_id = %pending.module_id, pid = self.pid, %cause, "privacy identity trampoline refused module spawn");
476            self.spawn_failure = Some(cause);
477            // No image is admitted on failure. Reach the entire fresh process
478            // group, including a module which spawned a helper before refusal.
479            if let Some(pid) = rustix::process::Pid::from_raw(self.pid as i32) {
480                let _ = rustix::process::kill_process_group(pid, rustix::process::Signal::KILL);
481            }
482            let _ = self.child.start_kill();
483        }
484    }
485
486    /// Releases this child's daemon-shutdown roster entry once its exit has
487    /// been recorded. The pid is already reaped and free for reuse, so the
488    /// entry must not outlive the record any longer than that.
489    fn release_roster(&mut self) {
490        self.roster_guard = None;
491    }
492
493    /// Kill the child and, where containment is available, its process tree.
494    ///
495    /// `Child::kill` is `TerminateProcess` scoped to one pid, so a module with a
496    /// helper process leaked the helper — the Synapse embedding module's CUDA
497    /// worker holds the GPU allocation, so the leak cost VRAM until the next
498    /// restart of something else. Terminating the job reaches grandchildren that
499    /// a tree walk cannot, including one whose parent has already exited and
500    /// been reparented away.
501    ///
502    /// Best-effort like `request_graceful_stop`: a job failure is logged and the
503    /// direct-child kill still decides the outcome, so containment can never
504    /// change whether a module is reported as stopped.
505    fn start_kill(&mut self) -> io::Result<()> {
506        #[cfg(windows)]
507        if let Some(job) = &self.job {
508            if let Err(error) = job.terminate() {
509                debug!(
510                    error = %error,
511                    "job termination failed; the direct-child kill still owns the outcome"
512                );
513            }
514        }
515        #[cfg(target_os = "linux")]
516        kill_module_cgroup(self.cgroup_placement.as_ref(), &self.module_id);
517        self.child.start_kill()
518    }
519
520    async fn drain_stderr(&mut self, module_id: &str) {
521        if let Some(pump) = self.stdout_pump.take() {
522            settle_stdout_pump(module_id, pump, STDERR_PUMP_DRAIN_TIMEOUT).await;
523        }
524
525        let Some(pump) = self.stderr_pump.take() else {
526            return;
527        };
528        settle_stderr_pump(
529            module_id,
530            &self.stderr_ring,
531            pump,
532            STDERR_PUMP_DRAIN_TIMEOUT,
533        )
534        .await;
535    }
536}
537
538/// Bound the restart's wait, not the lifetime of the exited child's stdout
539/// reader. Slow pipe reads or capture writes must not turn the bound into lost
540/// output; dropping the handle lets the reader finish at EOF, like stderr.
541async fn settle_stdout_pump(module_id: &str, mut pump: JoinHandle<()>, bound: Duration) {
542    match timeout(bound, &mut pump).await {
543        Ok(Ok(())) => {}
544        Ok(Err(error)) => {
545            warn!(module_id, error = %error, "stdout pump ended unexpectedly");
546        }
547        Err(_) => {
548            drop(pump);
549            warn!(
550                module_id,
551                waited = ?bound,
552                "stdout pipe of the exited process has not drained; its reader keeps running without delaying the restart"
553            );
554        }
555    }
556}
557
558/// The reader task for one process's stderr, with the ring generation its
559/// lines are attributed to.
560struct StderrPump {
561    task: JoinHandle<()>,
562    generation: u64,
563}
564
565/// Retire an exited process's stderr reader and wait up to `bound` for it to
566/// reach EOF. A reader still running at the bound is detached, not stopped: it
567/// keeps filling the exited process's section of the ring until its pipe
568/// closes, and the tail reads `Incomplete` until then. See
569/// [`STDERR_PUMP_DRAIN_TIMEOUT`] for why.
570async fn settle_stderr_pump(
571    module_id: &str,
572    ring: &Arc<Mutex<StderrRing>>,
573    pump: StderrPump,
574    bound: Duration,
575) {
576    let lock = || ring.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
577    let StderrPump {
578        mut task,
579        generation,
580    } = pump;
581    lock().retire_pump(generation);
582    match timeout(bound, &mut task).await {
583        Ok(Ok(())) => {}
584        Ok(Err(err)) => {
585            let mut ring = lock();
586            ring.mark_incomplete_from(generation, format!("stderr pump ended unexpectedly: {err}"));
587            ring.finish_pump(generation);
588            warn!(module_id, error = %err, "stderr pump ended before clean EOF");
589        }
590        Err(_) => {
591            // Dropping the handle detaches the task; it ends at EOF on its pipe.
592            drop(task);
593            lock().mark_pump_late(
594                generation,
595                format!(
596                    "stderr of the exited process had not reached EOF {bound:?} after it was \
597                     retired (a descendant may still hold the pipe open); lines it still \
598                     writes are kept in that process's section"
599                ),
600            );
601            warn!(
602                module_id,
603                waited = ?bound,
604                "stderr pipe of the exited process is still open; its reader keeps running without delaying the restart"
605            );
606        }
607    }
608}
609
610fn registration_release_events() -> &'static watch::Sender<u64> {
611    static EVENTS: OnceLock<watch::Sender<u64>> = OnceLock::new();
612    EVENTS.get_or_init(|| {
613        let (sender, _receiver) = watch::channel(0);
614        sender
615    })
616}
617
618pub(crate) fn notify_registration_release() {
619    let events = registration_release_events();
620    let next_generation = (*events.borrow()).wrapping_add(1);
621    events.send_replace(next_generation);
622}
623
624/// How to launch one singleton module process.
625#[derive(Debug, Clone, PartialEq, Eq)]
626pub struct ModuleSpec {
627    pub module_id: String,
628    pub program: PathBuf,
629    pub args: Vec<String>,
630    pub env: Vec<(String, String)>,
631    /// When true this is a reserved module: each spawn gets a fresh one-time launch
632    /// nonce that the child must echo in its HELLO, so only the daemon-spawned
633    /// process can register this module_id (a security-boundary module like the
634    /// credential vault must not be impersonable while it is down/restarting).
635    pub reserved: bool,
636    /// Module-id prefixes this supervised module owns for reserved HELLO checks.
637    /// Prefixes come from daemon config and must end in `:` before they reach the
638    /// supervisor; the owner module's current spawn nonce authorizes claims under
639    /// each prefix.
640    pub reserved_prefixes: Vec<String>,
641    /// The wire protocol this module speaks, as DECLARED in daemon config.
642    ///
643    /// [`ModuleProtocol::None`] changes five things and nothing else: wire health
644    /// probing is suppressed (an optional HTTP probe can replace it), teardown sends SIGTERM before waiting,
645    /// `route.open` is refused, the spawn passes NO `--subc <path>` argument
646    /// and NO launch nonce, and a clean exit the daemon did not request is
647    /// restarted as a crash rather than recorded as a stop (see `on_child_exit`:
648    /// a stock program exits 0 on a stray SIGTERM, and a stop would leave it
649    /// down for good). `SUBC_MODULE_ID` still goes into the environment,
650    /// because a process ignores an environment variable it does not read.
651    ///
652    /// The argument is the part that cannot be "harmless to a process that
653    /// ignores it": a stock binary exits on an unknown flag before it listens
654    /// (`nats-server`: "flag provided but not defined: -subc"), which is how the
655    /// first conformance run against this mode found it. The nonce is withheld
656    /// because a process that will never present it gains nothing from holding
657    /// it, and a secret in the environment of a process that does not need it is
658    /// a leak surface for no benefit.
659    pub protocol: ModuleProtocol,
660    /// Whether two processes of this module may run at once, which is what a
661    /// blue/green swap does for the length of its overlap. Declared in daemon
662    /// config because the daemon must be able to answer it while the module is
663    /// down, and so a module cannot talk itself into it after registering.
664    pub overlap: ModuleOverlap,
665}
666
667/// Whether a module tolerates a second process of itself running alongside.
668///
669/// Most modules are single-writer on their store (a WAL, a capture log, a
670/// resident index behind a writer barrier), and two processes on one store
671/// corrupt it. So a swap, which overlaps the old and new process by design,
672/// is refused unless the module's config opts in with `overlap: "safe"`.
673#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
674pub enum ModuleOverlap {
675    /// Never run two processes of this module at once. The default.
676    #[default]
677    Exclusive,
678    /// The module has said a second process of itself is harmless for the
679    /// length of a swap.
680    ///
681    /// Declare it only if a second instance can run for a few seconds without
682    /// touching ANY single-writer store: every database, WAL, index, projector
683    /// and scheduled job the module owns. A lease on part of that state is not
684    /// enough. broca's session lease guards WAL appends while its run index, its
685    /// store projector and its archive fold timer (which unlinks live WAL files)
686    /// stay single-writer, so broca is exclusive despite holding a lease. The
687    /// refusal only fires after this has been decided, so the decision is the
688    /// check.
689    Safe,
690}
691
692impl ModuleOverlap {
693    pub fn as_str(self) -> &'static str {
694        match self {
695            Self::Exclusive => "exclusive",
696            Self::Safe => "safe",
697        }
698    }
699}
700
701/// Environment variable telling a spawned module which case it was started
702/// for, before it sends HELLO. Only a swap candidate carries it, as
703/// [`SPAWN_ROLE_SWAP_CANDIDATE`]; every other spawn has it removed.
704///
705/// It chooses a warm-up budget, nothing else: a swap candidate can warm for
706/// longer because nobody waits on it, while a plain restart must flip ready
707/// quickly because callers see `module_warming` until it does. Absence means
708/// plain restart, the safe reading. The daemon trusts nothing about it; the
709/// candidate is proven by its launch nonce at HELLO.
710pub const SUBC_SPAWN_ROLE_ENV: &str = "SUBC_SPAWN_ROLE";
711/// The one value of [`SUBC_SPAWN_ROLE_ENV`] the daemon sets.
712pub const SPAWN_ROLE_SWAP_CANDIDATE: &str = "swap_candidate";
713/// How long a swap waits for its candidate to register and declare itself
714/// ready when the operator does not say. A module warming as a swap candidate
715/// may take up to 90 s (aft's ceiling, the largest in the fleet), so the
716/// daemon allows that plus time to start the process and send HELLO.
717pub const DEFAULT_SWAP_READY_TIMEOUT: Duration = Duration::from_secs(100);
718
719/// Bounded restart policy for crash exits.
720///
721/// `max_restarts` is the number of replacement processes allowed after the
722/// initial spawn WITHIN `window`. After that many crash restarts inside one
723/// window the module enters [`ModuleState::Failed`] and the supervisor stops
724/// the crash loop.
725///
726/// The budget is a RATE, not a lifetime total. It used to be a lifetime total,
727/// and that only survived because crashes were rare: a module that crashed
728/// three times across a week was disabled forever by crashes that had nothing
729/// to do with each other. That stopped being survivable once modules began
730/// exiting non-zero whenever the daemon's connection to them drops, because
731/// then every daemon-side connection drop spends a unit of the same budget and
732/// one flappy hour permanently stops a healthy module. Restarts older than
733/// `window` release their slot, so a module that crashed twice yesterday has a
734/// full budget today, while a genuine crash loop -- which is fast by
735/// definition -- still reaches the cap and stops.
736#[derive(Debug, Clone, Copy, PartialEq, Eq)]
737pub struct RestartPolicy {
738    pub max_restarts: u32,
739    /// Base delay before a crash replacement. The actual delay escalates with
740    /// the number of recent crash replacements and is capped by `max_backoff`.
741    pub backoff: Duration,
742    /// Maximum delay before a crash replacement.
743    pub max_backoff: Duration,
744    /// The span `max_restarts` is counted over. `Duration::ZERO` makes the
745    /// budget effectively infinite (nothing is ever in-window), which is why
746    /// daemon config refuses `window_secs: 0` rather than quietly accepting it.
747    pub window: Duration,
748}
749
750impl RestartPolicy {
751    /// A policy with the default crash window. Callers that care about the
752    /// window say so with [`Self::with_window`]; the ones that do not are
753    /// asking for the standard rate limit, not for no limit.
754    pub fn new(max_restarts: u32, backoff: Duration) -> Self {
755        Self {
756            max_restarts,
757            backoff,
758            max_backoff: DEFAULT_MAX_BACKOFF,
759            window: DEFAULT_RESTART_WINDOW,
760        }
761    }
762
763    pub fn with_max_backoff(mut self, max_backoff: Duration) -> Self {
764        self.max_backoff = max_backoff;
765        self
766    }
767
768    pub fn with_window(mut self, window: Duration) -> Self {
769        self.window = window;
770        self
771    }
772
773    /// Calculate the capped exponential delay for the next crash replacement.
774    /// `restart_in_window` is zero for the first replacement after an operator
775    /// action (restart, reload, re-enable) cleared the crash ring, or after all
776    /// older crash replacements have aged out of the window.
777    fn delay_for_restart(&self, restart_in_window: u32) -> Duration {
778        if self.backoff.is_zero() || self.max_backoff.is_zero() {
779            return Duration::ZERO;
780        }
781
782        let mut delay = self.backoff;
783        for _ in 0..restart_in_window {
784            if delay >= self.max_backoff {
785                return self.max_backoff;
786            }
787            delay = delay
788                .checked_mul(10)
789                .unwrap_or(self.max_backoff)
790                .min(self.max_backoff);
791        }
792        delay.min(self.max_backoff)
793    }
794
795    /// The one sentence that explains a budget-exhausted stop, used for both the
796    /// log line and the terminal record so the two cannot drift. It names the
797    /// window because `max_restarts=3` alone reads as a lifetime cap, which is
798    /// exactly what this budget is not.
799    fn budget_exhausted_detail(&self) -> String {
800        format!(
801            "crash budget exhausted: max_restarts={} within window_secs={}",
802            self.max_restarts,
803            self.window.as_secs()
804        )
805    }
806}
807
808impl Default for RestartPolicy {
809    fn default() -> Self {
810        Self {
811            max_restarts: DEFAULT_MAX_RESTARTS,
812            backoff: DEFAULT_BACKOFF,
813            max_backoff: DEFAULT_MAX_BACKOFF,
814            window: DEFAULT_RESTART_WINDOW,
815        }
816    }
817}
818
819#[derive(Debug, Clone, Copy, PartialEq, Eq)]
820struct CrashRestartSchedule {
821    restart_in_window: u32,
822    delay: Duration,
823}
824
825/// Whether the daemon itself will bring this module back after the exit being
826/// handled: it is enabled AND its in-window crash restarts are below the cap.
827///
828/// Takes `&mut` because reading the budget prunes it. Instants that fell out of
829/// the window are dropped here rather than by a timer, so the count is right
830/// the moment somebody asks and no bookkeeping runs for idle modules.
831fn daemon_will_restart(
832    state: &mut SupervisorSnapshot,
833    policy: &RestartPolicy,
834    now: Instant,
835) -> bool {
836    state.enabled && state.crash_restarts_in_window(policy.window, now) < policy.max_restarts
837}
838
839const DEFAULT_HEALTH_CADENCE: Duration = Duration::from_secs(30);
840const DEFAULT_HEALTH_DEADLINE: Duration = Duration::from_secs(5);
841const DEFAULT_HEALTH_FAILURE_THRESHOLD: u32 = 3;
842const MAX_HEALTH_METRICS_BYTES: usize = 16 * 1024;
843
844#[derive(Debug, Clone, Copy, PartialEq, Eq)]
845pub enum HealthAction {
846    Report,
847    Restart,
848    Alert,
849}
850
851impl fmt::Display for HealthAction {
852    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
853        f.write_str(match self {
854            Self::Report => "report",
855            Self::Restart => "restart",
856            Self::Alert => "alert",
857        })
858    }
859}
860
861#[derive(Debug, Clone, PartialEq, Eq)]
862pub struct HealthConfig {
863    /// Optional loopback HTTP endpoint for a managed non-wire process.
864    /// Changing it applies live on rescan; the process protocol changes only
865    /// at its next spawn.
866    pub http: Option<String>,
867    pub cadence: Duration,
868    pub deadline: Duration,
869    pub failure_threshold: u32,
870    pub on_degraded: HealthAction,
871    pub on_failing: HealthAction,
872    pub critical: bool,
873}
874
875impl Default for HealthConfig {
876    fn default() -> Self {
877        Self {
878            http: None,
879            cadence: DEFAULT_HEALTH_CADENCE,
880            deadline: DEFAULT_HEALTH_DEADLINE,
881            failure_threshold: DEFAULT_HEALTH_FAILURE_THRESHOLD,
882            on_degraded: HealthAction::Report,
883            on_failing: HealthAction::Report,
884            critical: false,
885        }
886    }
887}
888
889/// The supervisor's view of one module's health, relayed to clients over
890/// channel-0 and rendered by `ck health`.
891///
892/// THIS TYPE IS WHERE THE ABSENCE MEANINGS ARE CREATED, which is why they are
893/// stated here rather than only at the wire type a consumer reads. A reader can
894/// look up what `None` means; only a writer can silently change it, and the
895/// writer has no reason to go looking at a downstream contract before editing.
896///
897/// `last_probe_ms: None` MEANS NEVER PROBED, not probed-long-ago. It is cleared
898/// back to `None` on re-registration precisely so a respawned module does not
899/// carry its predecessor's timestamp — so an old value and an absent one call for
900/// opposite readings, and anything that defaulted this to a number would make a
901/// never-probed module indistinguishable from one probed at the epoch.
902///
903/// `detail` and `metrics` are `None` when the module published none on this
904/// probe, which does not mean it reported nothing wrong — it is also the shape
905/// when the probe never reached it. `last_probe_ms` is what separates those.
906#[derive(Debug, Clone, PartialEq)]
907pub struct ModuleHealthStatus {
908    pub status: SupervisorHealthStatus,
909    pub last_probe_ms: Option<u64>,
910    pub detail: Option<String>,
911    pub metrics: Option<Value>,
912    pub consecutive_failures: u32,
913    /// Number of replies received after a recurring health probe's deadline.
914    /// Unlike a timeout, every increment proves the module was alive.
915    pub late_answer_count: u64,
916    /// End-to-end latency of the newest late reply, measured from probe start.
917    pub last_late_answer_latency_ms: Option<u64>,
918    pub last_action: Option<String>,
919    /// Set together with `last_action`; the pair moves as one, and both being
920    /// absent means no escalation has ever been taken rather than that the last
921    /// one succeeded.
922    pub last_action_ms: Option<u64>,
923}
924
925impl Default for ModuleHealthStatus {
926    fn default() -> Self {
927        Self {
928            status: SupervisorHealthStatus::Unknown,
929            last_probe_ms: None,
930            detail: None,
931            metrics: None,
932            consecutive_failures: 0,
933            late_answer_count: 0,
934            last_late_answer_latency_ms: None,
935            last_action: None,
936            last_action_ms: None,
937        }
938    }
939}
940
941/// Typed lifecycle state for a supervised module.
942#[derive(Debug, Clone, Copy, PartialEq, Eq)]
943pub enum ModuleState {
944    Starting,
945    Running,
946    Unresponsive,
947    Restarting,
948    Draining,
949    Stopped,
950    Failed,
951    Disabled,
952}
953
954impl fmt::Display for ModuleState {
955    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
956        f.write_str(match self {
957            Self::Starting => "starting",
958            Self::Running => "running",
959            Self::Unresponsive => "unresponsive",
960            Self::Restarting => "restarting",
961            Self::Draining => "draining",
962            Self::Stopped => "stopped",
963            Self::Failed => "failed",
964            Self::Disabled => "disabled",
965        })
966    }
967}
968
969/// Supervisor classification of a child-process exit.
970#[derive(Debug, Clone, Copy, PartialEq, Eq)]
971pub enum ExitKind {
972    Clean,
973    Crash,
974    DeliberateSeverance,
975}
976
977impl From<ExitKind> for TerminalExitKind {
978    fn from(kind: ExitKind) -> Self {
979        match kind {
980            ExitKind::Clean => Self::Clean,
981            ExitKind::Crash => Self::Crash,
982            ExitKind::DeliberateSeverance => Self::DeliberateSeverance,
983        }
984    }
985}
986
987/// Exact process identity retained when a supervised module registers its
988/// connection. PID reuse makes a PID alone insufficient evidence of ownership.
989#[derive(Debug, Clone, Copy, PartialEq, Eq)]
990pub(crate) struct ProcessIdentity {
991    pub(crate) pid: u32,
992    pub(crate) start_time: u64,
993}
994
995/// Last observed child exit, if any.
996#[derive(Debug, Clone, PartialEq, Eq)]
997pub struct ExitReport {
998    pub kind: ExitKind,
999    pub code: Option<i32>,
1000    pub signal: Option<i32>,
1001    pub at_ms: u64,
1002}
1003
1004/// Point-in-time module status answerable by subc without forwarding to the
1005/// module process.
1006#[derive(Debug, Clone, PartialEq)]
1007pub struct ModuleStatus {
1008    pub module_id: String,
1009    /// A previous daemon's hidden process, not a child owned by this supervisor.
1010    pub waiting_for_previous_pid: Option<u32>,
1011    /// The daemon's Windows per-spawn record: `pipe` after the process it
1012    /// spawned read all nonce bytes, otherwise `env` for the offered copy.
1013    /// List/status reports use this record, not the source the module sends
1014    /// in its registration message's manifest.provenance.launch_nonce_source.
1015    pub launch_nonce_source: Option<String>,
1016    pub state: ModuleState,
1017    pub enabled: bool,
1018    pub process_alive: bool,
1019    pub registration_active: bool,
1020    /// The module's declared wire protocol, carried beside `live` because it is
1021    /// what makes `live` readable: the two fields answer one question together.
1022    /// While a process is alive this is its launch declaration, not a later
1023    /// pending-reload edit. When down it is the configured next launch protocol.
1024    pub protocol: ModuleProtocol,
1025    /// Whether the module is serving, under the strongest definition the daemon
1026    /// can assert for its protocol.
1027    ///
1028    /// A subc module must also be REGISTERED: its process being alive says
1029    /// nothing about whether it can take a request. A `protocol: "none"` module
1030    /// never registers, so that term is dropped and this falls back to "enabled,
1031    /// running, and the process the daemon launched is alive" -- which is all
1032    /// the daemon observes about a process that speaks no subc wire. It stays a
1033    /// `bool` on the wire for compatibility; renderers pair it with `protocol`
1034    /// rather than printing it bare.
1035    pub live: bool,
1036    /// Crash restarts spent INSIDE `restart_window` as of this read. Older
1037    /// restarts have already released their slot, so this count can go down
1038    /// without anybody touching the module.
1039    pub restart_count: u32,
1040    /// Replacement processes spawned over this module's entire supervisor lifetime;
1041    /// unlike `restart_count`, this value is never reset by an operator action
1042    /// and never falls out of a window.
1043    pub lifetime_restarts: u32,
1044    pub spawn_generation: u64,
1045    /// The budget `restart_count` is spent against. Carried alongside the count
1046    /// because the count alone does not say how close the module is to being
1047    /// disabled, and reporting one without the other is what makes an
1048    /// about-to-be-retired module look ordinary.
1049    pub max_restarts: u32,
1050    /// The span `restart_count` is counted over. Carried with the pair above for
1051    /// the same reason they are carried together: "2 of 3" means one thing for a
1052    /// ten-minute window and something else entirely for a lifetime.
1053    pub restart_window: Duration,
1054    /// Effective drain and restart timing policy used by this running module.
1055    /// These values are carried together with the restart budget so status
1056    /// readers can compare configured intent with what the supervisor applied.
1057    pub drain_timeout: Duration,
1058    pub restart_backoff: Duration,
1059    pub restart_max_backoff: Duration,
1060    /// The module's process. On macOS this stays absent while the `ck-subc`
1061    /// launch trampoline is still running in that pid, and appears once the
1062    /// exec acknowledgement confirms the module image has replaced it. Launch
1063    /// time and the supervisor's own hold on the process are unaffected.
1064    pub pid: Option<u32>,
1065    pub spawned_at_ms: Option<u64>,
1066    pub spawned_from: Option<PathBuf>,
1067    pub process_start_time: Option<u64>,
1068    pub last_exit: Option<ExitReport>,
1069    pub health: ModuleHealthStatus,
1070}
1071
1072#[cfg(test)]
1073#[derive(Debug, Clone, Copy, PartialEq)]
1074struct ActorSelectCheckpoint {
1075    generation: u64,
1076    turn: u64,
1077    registered_connection: Option<ConnectionId>,
1078    next_probe_at: Option<Instant>,
1079    wake_after: Option<Duration>,
1080}
1081
1082#[derive(Debug, Clone, PartialEq)]
1083struct SupervisorSnapshot {
1084    #[cfg(windows)]
1085    pipe_consumed: Option<Arc<OnceLock<()>>>,
1086    /// Counts running-child loop turns, not executor polls of a parked wait.
1087    #[cfg(test)]
1088    actor_turns: u64,
1089    /// Running select after exec confirmation and with no unconsumed registry
1090    /// event. Recording a spawn or starting a loop turn is not this barrier.
1091    #[cfg(test)]
1092    actor_select: Option<ActorSelectCheckpoint>,
1093    state: ModuleState,
1094    enabled: bool,
1095    process_alive: bool,
1096    spawned_protocol: Option<ModuleProtocol>,
1097    spawn_failure: Option<String>,
1098    waiting_for_previous_pid: Option<u32>,
1099    /// When each crash restart was spent, oldest first. This IS the crash
1100    /// budget: its in-window length is the count an operator sees and the count
1101    /// the restart decision is made against, so there is no second counter that
1102    /// can disagree with it. Bounded by `max_restarts`, and cleared by the same
1103    /// operator actions that used to zero the old lifetime counter.
1104    crash_restarts: VecDeque<Instant>,
1105    lifetime_restarts: u32,
1106    /// Successful child spawns in this daemon incarnation.
1107    ///
1108    /// `lifetime_restarts` was considered and rejected: it starts at zero
1109    /// (line 640), successful initial/operator spawns in `set_running` do not
1110    /// increment it (lines 5264-5274), and crash/deliberate retry bookkeeping
1111    /// increments before a successful replacement exists (lines 604, 3846,
1112    /// and 3921), so a failed spawn can consume it. This counter moves only
1113    /// when a live PID is accepted below.
1114    spawn_generation: u64,
1115    pid: Option<u32>,
1116    #[cfg(target_os = "macos")]
1117    report_ready: Option<Arc<OnceLock<()>>>,
1118    /// Last reaped child, retained after current process facts are cleared.
1119    reaped_pid: Option<u32>,
1120    /// Whether the command-serving supervision loop has a scheduled respawn.
1121    respawn_pending: bool,
1122    /// A second restart is waiting for the replacement already scheduled.
1123    coalesced_restart_pending: bool,
1124    spawned_at_ms: Option<u64>,
1125    spawned_from: Option<PathBuf>,
1126    spawned_file_identity: Option<SpawnedFileIdentity>,
1127    process_start_time: Option<u64>,
1128    deliberate_severance: Option<ProcessIdentity>,
1129    last_exit: Option<ExitReport>,
1130    /// Diagnostic attached to the next drain's terminal record, if any.
1131    drain_disposition_detail: Option<String>,
1132    health: ModuleHealthStatus,
1133    /// Whether the current process was started as a swap candidate and so
1134    /// lives in the module's alternate cgroup. The next swap's candidate takes
1135    /// the other one, so the two processes of a swap never share a cgroup. A
1136    /// plain spawn always uses the primary cgroup.
1137    in_alternate_slot: bool,
1138    /// Whether the current `Draining` state ends in a replacement process
1139    /// (restart, reload, health restart) rather than a stop. Only meaningful
1140    /// while `state` is `Draining`; every entry into that state rewrites it.
1141    /// It is what lets route.open answer the retryable `module_reloading` to a
1142    /// consumer that reaches a still-registered process mid-restart, instead of
1143    /// the `supervisor_not_live` a stop or disable deserves.
1144    draining_to_replace: bool,
1145    /// Whether a configuration update has been applied since the current
1146    /// process was spawned, so that process runs an older spec than the one
1147    /// the supervisor now holds. A queued restart is only coalesced into a
1148    /// fresher process when this is false: a restart requested to pick up a
1149    /// new configuration must not be satisfied by a process that predates it.
1150    configuration_updated_since_spawn: bool,
1151}
1152
1153impl SupervisorSnapshot {
1154    /// The pid that status, provenance and resource readings may report. While
1155    /// the launch trampoline still runs in the pid, reading its executable or
1156    /// resource use would describe `ck-subc`, not the module, so none is
1157    /// reported until the exec acknowledgement confirms the module image.
1158    fn reported_pid(&self) -> Option<u32> {
1159        #[cfg(target_os = "macos")]
1160        if self
1161            .report_ready
1162            .as_ref()
1163            .is_some_and(|ready| ready.get().is_none())
1164        {
1165            return None;
1166        }
1167        self.pid
1168    }
1169
1170    fn starting() -> Self {
1171        Self::new(ModuleState::Starting, true)
1172    }
1173
1174    fn disabled() -> Self {
1175        Self::new(ModuleState::Disabled, false)
1176    }
1177
1178    fn failed() -> Self {
1179        Self::new(ModuleState::Failed, true)
1180    }
1181
1182    /// Crash restarts still inside `window`, having dropped the ones that are
1183    /// not. Pruning on read is what makes the budget a rate: an instant older
1184    /// than the window stops holding a slot the moment anybody counts.
1185    fn crash_restarts_in_window(&mut self, window: Duration, now: Instant) -> u32 {
1186        while let Some(oldest) = self.crash_restarts.front() {
1187            if now.duration_since(*oldest) > window {
1188                self.crash_restarts.pop_front();
1189            } else {
1190                break;
1191            }
1192        }
1193        u32::try_from(self.crash_restarts.len()).unwrap_or(u32::MAX)
1194    }
1195
1196    /// Spend one unit of the crash budget and record the restart in the ledger.
1197    ///
1198    /// The ring is bounded by the cap because more than `max_restarts` in-window
1199    /// instants can never be reached (the caller refuses the restart first), so
1200    /// anything beyond that is an unbounded queue waiting to happen.
1201    fn record_crash_restart(&mut self, policy: &RestartPolicy, now: Instant) {
1202        self.crash_restarts.push_back(now);
1203        while self.crash_restarts.len() > policy.max_restarts as usize {
1204            self.crash_restarts.pop_front();
1205        }
1206        self.lifetime_restarts += 1;
1207    }
1208
1209    /// Reserve one crash-restart slot and calculate the delay before respawning.
1210    /// The count is captured before recording this restart, so the first retry
1211    /// uses the base delay and each later in-window retry escalates once.
1212    fn next_crash_restart(
1213        &mut self,
1214        policy: &RestartPolicy,
1215        now: Instant,
1216    ) -> Option<CrashRestartSchedule> {
1217        let restart_in_window = self.crash_restarts_in_window(policy.window, now);
1218        if restart_in_window >= policy.max_restarts {
1219            return None;
1220        }
1221        self.record_crash_restart(policy, now);
1222        Some(CrashRestartSchedule {
1223            restart_in_window,
1224            delay: policy.delay_for_restart(restart_in_window),
1225        })
1226    }
1227
1228    /// Give the module its full budget back, as an operator restart, reload, or
1229    /// re-enable does. `lifetime_restarts` deliberately does not move: it is the
1230    /// ledger of what actually happened, and an operator action does not unmake
1231    /// the crashes.
1232    fn clear_crash_restarts(&mut self) {
1233        self.crash_restarts.clear();
1234    }
1235
1236    fn new(state: ModuleState, enabled: bool) -> Self {
1237        Self {
1238            #[cfg(test)]
1239            actor_turns: 0,
1240            #[cfg(test)]
1241            actor_select: None,
1242            state,
1243            enabled,
1244            process_alive: false,
1245            #[cfg(windows)]
1246            pipe_consumed: None,
1247            spawned_protocol: None,
1248            spawn_failure: None,
1249            waiting_for_previous_pid: None,
1250            crash_restarts: VecDeque::new(),
1251            lifetime_restarts: 0,
1252            spawn_generation: 0,
1253            pid: None,
1254            #[cfg(target_os = "macos")]
1255            report_ready: None,
1256            reaped_pid: None,
1257            respawn_pending: false,
1258            coalesced_restart_pending: false,
1259            spawned_at_ms: None,
1260            spawned_from: None,
1261            spawned_file_identity: None,
1262            process_start_time: None,
1263            deliberate_severance: None,
1264            last_exit: None,
1265            drain_disposition_detail: None,
1266            health: ModuleHealthStatus::default(),
1267            in_alternate_slot: false,
1268            draining_to_replace: false,
1269            configuration_updated_since_spawn: false,
1270        }
1271    }
1272}
1273
1274type SharedSnapshot = Arc<Mutex<SupervisorSnapshot>>;
1275
1276type SpawnSubscriberKey = (ConnectionId, u64);
1277
1278#[derive(Debug)]
1279struct SpawnSubscriber {
1280    version: u8,
1281    frames: mpsc::Sender<Frame>,
1282    /// Tells this subscriber's forwarder that it was dropped for lagging, and
1283    /// from which event. The full frame channel cannot carry that news, so it
1284    /// travels beside it; see `SpawnEventFeed::subscribe`.
1285    lagged: Option<oneshot::Sender<SpawnCursor>>,
1286}
1287
1288#[derive(Debug)]
1289struct SpawnEventState {
1290    daemon_incarnation: String,
1291    seq: u64,
1292    capacity: usize,
1293    live: HashMap<String, LiveSpawn>,
1294    generations: HashMap<String, u64>,
1295    events: VecDeque<SpawnEvent>,
1296    subscribers: HashMap<SpawnSubscriberKey, SpawnSubscriber>,
1297}
1298
1299impl Default for SpawnEventState {
1300    fn default() -> Self {
1301        Self {
1302            daemon_incarnation: "unconfigured".to_string(),
1303            seq: 0,
1304            capacity: SPAWN_EVENT_RING_CAPACITY,
1305            live: HashMap::new(),
1306            generations: HashMap::new(),
1307            events: VecDeque::new(),
1308            subscribers: HashMap::new(),
1309        }
1310    }
1311}
1312
1313#[derive(Debug, Clone, Default)]
1314struct SpawnEventFeed(
1315    Arc<Mutex<SpawnEventState>>,
1316    #[cfg(test)] Arc<Mutex<Option<std::net::TcpStream>>>,
1317);
1318
1319#[derive(Debug, Clone, PartialEq, Eq)]
1320pub(crate) enum SpawnSubscribeRefusal {
1321    ForeignIncarnation { current: String },
1322    TooOld { oldest: SpawnCursor },
1323    Frame(String),
1324}
1325
1326impl SpawnEventFeed {
1327    fn configure_incarnation(&self, daemon_incarnation: String) {
1328        let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1329        state.daemon_incarnation = daemon_incarnation;
1330        state.seq = 0;
1331        state.live.clear();
1332        state.generations.clear();
1333        state.events.clear();
1334        state.subscribers.clear();
1335    }
1336
1337    fn cursor(state: &SpawnEventState) -> SpawnCursor {
1338        SpawnCursor {
1339            daemon_incarnation: state.daemon_incarnation.clone(),
1340            seq: state.seq,
1341        }
1342    }
1343
1344    fn snapshot(&self) -> SpawnSnapshot {
1345        let state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1346        let mut live = state.live.values().cloned().collect::<Vec<_>>();
1347        live.sort_by(|left, right| left.module_id.cmp(&right.module_id));
1348        SpawnSnapshot {
1349            cursor: Self::cursor(&state),
1350            ring_bound: state.capacity as u64,
1351            live,
1352        }
1353    }
1354
1355    fn emit_spawned(&self, module_id: &str, pid: u32, spawned_at_ms: u64) -> u64 {
1356        let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1357        let generation = state
1358            .generations
1359            .get(module_id)
1360            .copied()
1361            .unwrap_or(0)
1362            .checked_add(1)
1363            .expect("spawn generation exhausted");
1364        state.generations.insert(module_id.to_string(), generation);
1365        let live = LiveSpawn {
1366            module_id: module_id.to_string(),
1367            spawn_generation: generation,
1368            pid,
1369            spawned_at_ms,
1370        };
1371        state.live.insert(module_id.to_string(), live);
1372        Self::emit_locked(
1373            &mut state,
1374            SpawnEventKind::Spawned,
1375            module_id.to_string(),
1376            generation,
1377            pid,
1378            None,
1379            None,
1380        );
1381        generation
1382    }
1383
1384    fn emit_exited(&self, module_id: &str, exit_code: Option<i32>, exit_signal: Option<i32>) {
1385        let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1386        let Some(live) = state.live.remove(module_id) else {
1387            warn!(
1388                module_id,
1389                "terminal record had no live spawn event identity"
1390            );
1391            return;
1392        };
1393        Self::emit_locked(
1394            &mut state,
1395            SpawnEventKind::Exited,
1396            module_id.to_string(),
1397            live.spawn_generation,
1398            live.pid,
1399            exit_code,
1400            exit_signal,
1401        );
1402    }
1403
1404    /// Report the exit of a process that a swap has already replaced.
1405    ///
1406    /// `emit_exited` removes the module's live entry, which after a swap's
1407    /// cutover describes the promoted candidate, not the old process now
1408    /// exiting. This emits the old generation's exit and leaves the live entry
1409    /// alone unless it still names that generation.
1410    fn emit_superseded_exited(
1411        &self,
1412        module_id: &str,
1413        spawn_generation: u64,
1414        pid: u32,
1415        exit_code: Option<i32>,
1416        exit_signal: Option<i32>,
1417    ) {
1418        let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1419        if state
1420            .live
1421            .get(module_id)
1422            .is_some_and(|live| live.spawn_generation == spawn_generation)
1423        {
1424            state.live.remove(module_id);
1425        }
1426        Self::emit_locked(
1427            &mut state,
1428            SpawnEventKind::Exited,
1429            module_id.to_string(),
1430            spawn_generation,
1431            pid,
1432            exit_code,
1433            exit_signal,
1434        );
1435    }
1436
1437    #[allow(clippy::too_many_arguments)]
1438    fn emit_locked(
1439        state: &mut SpawnEventState,
1440        kind: SpawnEventKind,
1441        module_id: String,
1442        spawn_generation: u64,
1443        pid: u32,
1444        exit_code: Option<i32>,
1445        exit_signal: Option<i32>,
1446    ) {
1447        state.seq = state
1448            .seq
1449            .checked_add(1)
1450            .expect("spawn event sequence exhausted");
1451        let event = SpawnEvent {
1452            cursor: Self::cursor(state),
1453            kind,
1454            module_id,
1455            spawn_generation,
1456            pid,
1457            exit_code,
1458            exit_signal,
1459        };
1460        state.events.push_back(event.clone());
1461        while state.events.len() > state.capacity {
1462            state.events.pop_front();
1463        }
1464        let body = match serde_json::to_vec(&event) {
1465            Ok(body) => body,
1466            Err(error) => {
1467                error!(%error, "failed to serialize supervisor spawn event");
1468                return;
1469            }
1470        };
1471        state.subscribers.retain(|(connection_id, corr), subscriber| {
1472            let frame = Frame::build_with_version(
1473                subscriber.version,
1474                FrameType::StreamData,
1475                control_flags(),
1476                0,
1477                0,
1478                *corr,
1479                body.clone(),
1480            );
1481            match frame {
1482                Ok(frame) => {
1483                    if subscriber.frames.try_send(frame).is_ok() {
1484                        true
1485                    } else {
1486                        warn!(connection_id = connection_id.get(), corr, "dropping lagged supervisor spawn subscriber");
1487                        if let Some(lagged) = subscriber.lagged.take() {
1488                            let _ = lagged.send(event.cursor.clone());
1489                        }
1490                        false
1491                    }
1492                }
1493                Err(error) => {
1494                    warn!(connection_id = connection_id.get(), corr, %error, "dropping supervisor spawn subscriber after frame build failure");
1495                    false
1496                }
1497            }
1498        });
1499    }
1500
1501    fn subscribe(
1502        &self,
1503        connection_id: ConnectionId,
1504        corr: u64,
1505        version: u8,
1506        since: Option<SpawnCursor>,
1507        sink: FrameSink,
1508    ) -> Result<(), SpawnSubscribeRefusal> {
1509        let (frames, mut receiver) = mpsc::channel(SPAWN_SUBSCRIBER_BUFFER);
1510        let (lagged, mut lagged_rx) = oneshot::channel::<SpawnCursor>();
1511        {
1512            let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1513            let replay = if let Some(since) = since {
1514                if since.daemon_incarnation != state.daemon_incarnation {
1515                    return Err(SpawnSubscribeRefusal::ForeignIncarnation {
1516                        current: state.daemon_incarnation.clone(),
1517                    });
1518                }
1519                if let Some(oldest) = state.events.front().map(|event| event.cursor.clone()) {
1520                    if since.seq < oldest.seq.saturating_sub(1) {
1521                        return Err(SpawnSubscribeRefusal::TooOld { oldest });
1522                    }
1523                }
1524                state
1525                    .events
1526                    .iter()
1527                    .filter(|event| event.cursor.seq > since.seq)
1528                    .cloned()
1529                    .collect::<Vec<_>>()
1530            } else {
1531                Vec::new()
1532            };
1533            for event in replay {
1534                let body = serde_json::to_vec(&event)
1535                    .map_err(|error| SpawnSubscribeRefusal::Frame(error.to_string()))?;
1536                let frame = Frame::build_with_version(
1537                    version,
1538                    FrameType::StreamData,
1539                    control_flags(),
1540                    0,
1541                    0,
1542                    corr,
1543                    body,
1544                )
1545                .map_err(|error| SpawnSubscribeRefusal::Frame(error.to_string()))?;
1546                frames
1547                    .try_send(frame)
1548                    .map_err(|error| SpawnSubscribeRefusal::Frame(error.to_string()))?;
1549            }
1550            state.subscribers.insert(
1551                (connection_id, corr),
1552                SpawnSubscriber {
1553                    version,
1554                    frames,
1555                    lagged: Some(lagged),
1556                },
1557            );
1558        }
1559        // The lagged terminal is sent here, by the forwarder, rather than by
1560        // the emitter: at the moment of the drop the subscriber's own channel
1561        // is full, and writing to the connection sink directly from the emitter
1562        // would put the Error AHEAD of the events still queued in that channel
1563        // (and the emitter holds the feed lock, so it cannot await the sink).
1564        // Dropping the subscriber drops the only sender, so `recv` drains every
1565        // queued event and then returns `None`; only then is the Error sent, so
1566        // the client sees each event it can keep, then the reason it was cut.
1567        // Cancel and connection removal drop the oneshot unsent, so they end
1568        // the stream with no Error.
1569        tokio::spawn(async move {
1570            while let Some(frame) = receiver.recv().await {
1571                if sink.send(frame).await.is_err() {
1572                    return;
1573                }
1574            }
1575            let Ok(first_undelivered) = lagged_rx.try_recv() else {
1576                return;
1577            };
1578            match spawn_subscriber_lagged_frame(version, corr, first_undelivered) {
1579                Ok(frame) => {
1580                    let _ = sink.send(frame).await;
1581                }
1582                Err(error) => {
1583                    error!(%error, corr, "failed to build lagged spawn subscriber terminal frame");
1584                }
1585            }
1586        });
1587        Ok(())
1588    }
1589
1590    fn cancel(&self, connection_id: ConnectionId, corr: u64) -> bool {
1591        let Some(subscriber) = self
1592            .0
1593            .lock()
1594            .unwrap_or_else(|p| p.into_inner())
1595            .subscribers
1596            .remove(&(connection_id, corr))
1597        else {
1598            return false;
1599        };
1600        if let Ok(frame) = Frame::build_with_version(
1601            subscriber.version,
1602            FrameType::StreamEnd,
1603            control_flags(),
1604            0,
1605            0,
1606            corr,
1607            Vec::new(),
1608        ) {
1609            tokio::spawn(async move {
1610                let _ = subscriber.frames.send(frame).await;
1611            });
1612        }
1613        true
1614    }
1615
1616    fn remove_connection(&self, connection_id: ConnectionId) {
1617        self.0
1618            .lock()
1619            .unwrap_or_else(|p| p.into_inner())
1620            .subscribers
1621            .retain(|(subscriber_connection, _), _| *subscriber_connection != connection_id);
1622    }
1623
1624    #[cfg(any(test, feature = "test-support"))]
1625    fn set_capacity(&self, capacity: usize) {
1626        self.0.lock().unwrap_or_else(|p| p.into_inner()).capacity = capacity;
1627    }
1628
1629    #[cfg(any(test, feature = "test-support"))]
1630    fn subscriber_count(&self) -> usize {
1631        self.0
1632            .lock()
1633            .unwrap_or_else(|p| p.into_inner())
1634            .subscribers
1635            .len()
1636    }
1637}
1638
1639/// Narrow process-liveness signal published by supervisors and consumed by passive liveness polls.
1640/// The terminal Error a lagged spawn subscriber receives after its queued events.
1641fn spawn_subscriber_lagged_frame(
1642    version: u8,
1643    corr: u64,
1644    first_undelivered: SpawnCursor,
1645) -> Result<Frame, String> {
1646    let body = serde_json::to_vec(&subc_protocol::ErrorBody {
1647        code: SPAWN_SUBSCRIBER_LAGGED_CODE.to_string(),
1648        message: "spawn subscriber fell behind and was dropped; resubscribe from the last cursor received"
1649            .to_string(),
1650        detail: Some(serde_json::json!({
1651            "first_undelivered_cursor": first_undelivered
1652        })),
1653    })
1654    .map_err(|error| error.to_string())?;
1655    Frame::build_with_version(version, FrameType::Error, control_flags(), 0, 0, corr, body)
1656        .map_err(|error| error.to_string())
1657}
1658
1659pub trait ModuleProcessLiveness: Send + Sync {
1660    fn process_live(&self, module_id: &str) -> Option<bool>;
1661
1662    /// Whether the supervisor is replacing this module's process right now: an
1663    /// operator restart or reload, a health restart, or a crash respawn whose
1664    /// backoff is running. A module in that state is not live, but a consumer
1665    /// refused now should retry shortly rather than treat the target as gone.
1666    /// Stopped, failed, and disabled modules are not replacing.
1667    fn process_replacing(&self, _module_id: &str) -> bool {
1668        false
1669    }
1670}
1671
1672/// Shared process-liveness registry keyed by supervised `module_id`.
1673#[derive(Debug, Clone, Default)]
1674pub struct SupervisorProcessLiveness {
1675    snapshots: Arc<Mutex<HashMap<String, SharedSnapshot>>>,
1676}
1677
1678impl SupervisorProcessLiveness {
1679    pub fn new() -> Self {
1680        Self::default()
1681    }
1682
1683    fn track(&self, module_id: String, snapshot: SharedSnapshot) {
1684        let mut snapshots = self
1685            .snapshots
1686            .lock()
1687            .unwrap_or_else(|poisoned| poisoned.into_inner());
1688        snapshots.insert(module_id, snapshot);
1689    }
1690
1691    fn untrack_if_current(&self, module_id: &str, snapshot: &SharedSnapshot) {
1692        let mut snapshots = self
1693            .snapshots
1694            .lock()
1695            .unwrap_or_else(|poisoned| poisoned.into_inner());
1696        let is_current = snapshots
1697            .get(module_id)
1698            .map(|tracked| Arc::ptr_eq(tracked, snapshot))
1699            .unwrap_or(false);
1700        if is_current {
1701            snapshots.remove(module_id);
1702        }
1703    }
1704}
1705
1706impl ModuleProcessLiveness for SupervisorProcessLiveness {
1707    fn process_live(&self, module_id: &str) -> Option<bool> {
1708        let snapshot = {
1709            let snapshots = self
1710                .snapshots
1711                .lock()
1712                .unwrap_or_else(|poisoned| poisoned.into_inner());
1713            snapshots.get(module_id).cloned()
1714        }?;
1715        let snapshot = snapshot
1716            .lock()
1717            .unwrap_or_else(|poisoned| poisoned.into_inner());
1718        Some(snapshot.state == ModuleState::Running && snapshot.process_alive)
1719    }
1720
1721    fn process_replacing(&self, module_id: &str) -> bool {
1722        let Some(snapshot) = self
1723            .snapshots
1724            .lock()
1725            .unwrap_or_else(|poisoned| poisoned.into_inner())
1726            .get(module_id)
1727            .cloned()
1728        else {
1729            return false;
1730        };
1731        let snapshot = snapshot
1732            .lock()
1733            .unwrap_or_else(|poisoned| poisoned.into_inner());
1734        snapshot.enabled
1735            && match snapshot.state {
1736                ModuleState::Restarting => true,
1737                ModuleState::Draining => snapshot.draining_to_replace,
1738                ModuleState::Starting
1739                | ModuleState::Running
1740                | ModuleState::Unresponsive
1741                | ModuleState::Stopped
1742                | ModuleState::Failed
1743                | ModuleState::Disabled => false,
1744            }
1745    }
1746}
1747
1748#[cfg(test)]
1749#[derive(Debug, Default)]
1750struct ReloadExitRecordGate {
1751    reached: tokio::sync::Notify,
1752    resume: tokio::sync::Notify,
1753}
1754
1755#[derive(Debug, Clone, Copy)]
1756enum RespawnKind {
1757    Spawn,
1758    Reload,
1759}
1760
1761#[derive(Debug, Clone, Copy)]
1762struct PendingRespawn {
1763    deadline: Instant,
1764    kind: RespawnKind,
1765}
1766
1767type ReloadReply = oneshot::Sender<Result<(), SuperviseError>>;
1768
1769#[derive(Debug, Clone)]
1770struct SupervisorRuntimeConfig {
1771    start_requester: Option<Principal>,
1772    pending_activity_restart: bool,
1773    provenance_probe: ExecutableIdentityProbe,
1774    /// Restart operations hand their backoff to the loop, which keeps serving commands.
1775    scheduled_respawn: Arc<Mutex<Option<PendingRespawn>>>,
1776    /// A reload acknowledges completion only after its replacement registers.
1777    deferred_reload_reply: Arc<Mutex<Option<ReloadReply>>>,
1778    restart_policy: RestartPolicy,
1779    /// This module's RESOLVED drain budget: per-module config when present,
1780    /// else `default_drain_timeout`.
1781    drain_timeout: Duration,
1782    /// Shared with the status handle so the attested value changes atomically
1783    /// when a rescan updates the running drain policy.
1784    effective_drain_timeout: Arc<Mutex<Duration>>,
1785    /// The supervisor-wide fallback, kept so a configuration update that
1786    /// REMOVES the per-module override can re-resolve to it.
1787    default_drain_timeout: Duration,
1788    health: HealthConfig,
1789    connection_file_path: Option<PathBuf>,
1790    capture_logs_dir: Option<PathBuf>,
1791    forwarding: Option<Arc<ForwardingTable>>,
1792    /// The shared handle, so every spawn path (initial, restart, reload) records the
1793    /// reserved-module launch nonce the HELLO verifier checks against.
1794    supervisor_handle: Option<SupervisorHandle>,
1795    /// This module's stderr tail, shared with the [`SupervisedModule`] that answers
1796    /// status queries.
1797    ///
1798    /// One ring per module, held across every respawn. The lines explaining an exit
1799    /// are written BEFORE that exit, so a ring recreated per process would be empty
1800    /// exactly when it is asked for.
1801    stderr_ring: Arc<Mutex<StderrRing>>,
1802    terminal_ring: Arc<Mutex<TerminalRing>>,
1803    spawn_events: SpawnEventFeed,
1804    child_roster: ChildRoster,
1805    #[cfg(target_os = "linux")]
1806    cgroup_placement: Option<subc_cgroup::Placement>,
1807    #[cfg(test)]
1808    test_seed_stale_facts_before_enable_spawn: bool,
1809    #[cfg(test)]
1810    test_reload_exit_record_gate: Option<Arc<ReloadExitRecordGate>>,
1811}
1812
1813#[derive(Debug, Clone, PartialEq, Eq)]
1814struct SupervisedConfiguration {
1815    spec: ModuleSpec,
1816    health: HealthConfig,
1817}
1818
1819/// Shared daemon lookup table for supervised module handles.
1820///
1821/// Shared by clone between the [`Supervisor`] (which spawns processes) and the
1822/// channel-0 control handler (which verifies HELLOs and consumer route opens), so
1823/// launch nonces recorded at spawn are checked by the same daemon instance.
1824#[derive(Debug, Clone, Default)]
1825pub struct SupervisorHandle {
1826    activity: Arc<Mutex<Option<crate::activity::ActivityLedger>>>,
1827    activity_images: Arc<Mutex<HashMap<String, activity::ActivityImage>>>,
1828    modules: Arc<Mutex<HashMap<String, SupervisedModule>>>,
1829    /// Module ids the supervisor has taken on. An id is added BEFORE the
1830    /// module's first process is spawned and removed only when the module
1831    /// leaves the roster (see [`Self::retire`]), so it is always a superset of
1832    /// the keys of `modules`.
1833    ///
1834    /// `modules` cannot answer "is this module configured?" on its own: a
1835    /// [`SupervisedModule`] only exists once its process has been spawned, and
1836    /// a fast child can connect, register, sync its scopes and ask about them
1837    /// before the supervisor has inserted it. Answering "not configured" in that
1838    /// gap makes scope admission refuse with the terminal "will never sync"
1839    /// instead of the retryable "has not synced yet".
1840    configured_ids: Arc<Mutex<HashSet<String>>>,
1841    spawn_events: SpawnEventFeed,
1842    /// The current expected launch nonce for each reserved module_id. Set when the
1843    /// supervisor spawns the reserved module; checked when a HELLO claims that id. A
1844    /// non-reserved module never has an entry here and is never nonce-checked.
1845    /// Reserved module ids and the nonce that authorizes their next HELLO.
1846    /// `None` means RESERVED WITH NO LEGITIMATE HOLDER — a reserved module that
1847    /// has never been spawned (e.g. configured `enabled: false`) — and refuses
1848    /// every HELLO. Before this was expressible, a reserved-but-never-spawned id
1849    /// had NO entry and admitted anyone: the reservation protected the nonce
1850    /// holder, not the NAME (found live by CKCRED's canary probe registering
1851    /// against a reserved scratch id).
1852    reserved_nonces: Arc<Mutex<HashMap<String, Option<String>>>>,
1853    /// Module ids removed by an executed rescan and the unix-millisecond removal time.
1854    ///
1855    /// This is deliberately in-memory only: subc is state-free across daemon
1856    /// restarts, and the tombstone only explains the hours-after-removal window
1857    /// while this executing daemon is still alive. Do not persist it in a store.
1858    removal_tombstones: Arc<Mutex<HashMap<String, u64>>>,
1859    /// The current launch nonce for every supervised spawn. This is separate from
1860    /// reserved_nonces because consumer route.open attestation applies to all spawned
1861    /// modules, while HELLO id-squatting protection remains opt-in via `reserved`.
1862    spawn_nonces: Arc<Mutex<HashMap<String, String>>>,
1863    /// Reserved namespace prefixes mapped to the supervised owner module whose
1864    /// current spawn nonce authorizes HELLO claims below the prefix.
1865    ///
1866    /// Per §2.6 this is not a same-user security barrier: a same-user process can
1867    /// read the key file and launch nonce env. Like exact reserved ids, it prevents
1868    /// accidental collisions and lower-trust processes from squatting protected
1869    /// namespaces.
1870    reserved_prefix_owners: Arc<Mutex<HashMap<String, String>>>,
1871    /// Blue/green swaps in progress, by module id. An entry exists from just
1872    /// before the candidate process is spawned until the swap has failed, or
1873    /// has cut over and the old process is gone. While it exists, HELLO for the
1874    /// id is gated on the swap token (see [`Self::swap_hello_admission`]) and
1875    /// consumer attestation accepts both processes' nonces.
1876    swaps: Arc<Mutex<HashMap<String, OpenSwap>>>,
1877    /// Told when a swap promotes its candidate; see [`SwapPromotionObserver`].
1878    promotion_observer: PromotionObserverSlot,
1879    /// Serializes module-set reconciliation with operator lifecycle commands. Without
1880    /// this daemon-wide ordering, a rescan could retire or update a module while a
1881    /// concurrent reload still held its old handle and launch specification.
1882    operation_lock: Arc<AsyncMutex<()>>,
1883}
1884
1885/// Told when a swap has promoted its candidate to be the module's active
1886/// registration.
1887///
1888/// An ordinary HELLO runs the control plane's registration side effects (the
1889/// capability cache, the deny census, the requirement recompute) as it
1890/// registers. A swap candidate's HELLO does not, because it is not routable;
1891/// promotion is when those must run instead, and promotion happens in the
1892/// supervisor, which has no other way into the control handler.
1893pub(crate) trait SwapPromotionObserver: Send + Sync {
1894    fn swap_promoted(&self, registration: &crate::registry::ModuleRegistration);
1895}
1896
1897/// The installed [`SwapPromotionObserver`], held weakly: the observer (the
1898/// control handler) owns this handle, so a strong reference back would be a
1899/// cycle that keeps both alive.
1900#[derive(Clone, Default)]
1901struct PromotionObserverSlot(Arc<Mutex<Option<std::sync::Weak<dyn SwapPromotionObserver>>>>);
1902
1903impl fmt::Debug for PromotionObserverSlot {
1904    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1905        f.write_str("PromotionObserverSlot")
1906    }
1907}
1908
1909/// The nonces of one open swap.
1910#[derive(Debug, Clone)]
1911struct OpenSwap {
1912    /// The launch nonce minted for the candidate process. It is the swap
1913    /// token: the only thing that admits a HELLO into the candidate slot.
1914    candidate_nonce: String,
1915    /// The incumbent's launch nonce, captured when the swap opened. It is kept
1916    /// here because cutover moves the module's recorded spawn nonce to the
1917    /// candidate while the incumbent is still draining and its consumers are
1918    /// still attesting with this one.
1919    incumbent_nonce: Option<String>,
1920    /// Set once a HELLO has been admitted with the swap token, so the token
1921    /// admits one registration and cannot be replayed after cutover empties
1922    /// the candidate slot.
1923    candidate_admitted: bool,
1924}
1925
1926/// What the swap gate says about a HELLO. See
1927/// [`SupervisorHandle::swap_hello_admission`].
1928#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1929pub(crate) enum SwapHelloAdmission {
1930    /// No swap is open for the id (or the HELLO carries the incumbent's own
1931    /// nonce); the ordinary gates decide.
1932    NotSwapping,
1933    /// The HELLO carries the swap token: register it into the candidate slot.
1934    Candidate,
1935    /// A swap is open and the HELLO carries a nonce the supervisor did not
1936    /// mint for this id, no nonce, or a token already used.
1937    Refused,
1938}
1939
1940#[derive(Debug, Clone, PartialEq, Eq)]
1941pub(crate) enum ReservedHelloRejection {
1942    Exact {
1943        module_id: String,
1944    },
1945    Prefix {
1946        prefix: String,
1947        owner_module_id: String,
1948    },
1949}
1950
1951impl SupervisorHandle {
1952    pub fn new() -> Self {
1953        Self::default()
1954    }
1955
1956    pub(crate) fn spawn_snapshot(&self) -> SpawnSnapshot {
1957        self.spawn_events.snapshot()
1958    }
1959
1960    pub(crate) fn subscribe_spawns(
1961        &self,
1962        connection_id: ConnectionId,
1963        corr: u64,
1964        version: u8,
1965        since: Option<SpawnCursor>,
1966        sink: FrameSink,
1967    ) -> Result<(), SpawnSubscribeRefusal> {
1968        self.spawn_events
1969            .subscribe(connection_id, corr, version, since, sink)
1970    }
1971
1972    pub(crate) fn cancel_spawn_subscription(&self, connection_id: ConnectionId, corr: u64) -> bool {
1973        self.spawn_events.cancel(connection_id, corr)
1974    }
1975
1976    pub(crate) fn remove_spawn_subscribers(&self, connection_id: ConnectionId) {
1977        self.spawn_events.remove_connection(connection_id);
1978    }
1979
1980    #[cfg(any(test, feature = "test-support"))]
1981    pub fn set_spawn_event_capacity_for_test(&self, capacity: usize) {
1982        assert!(capacity > 0, "spawn event capacity must be non-zero");
1983        self.spawn_events.set_capacity(capacity);
1984    }
1985
1986    #[cfg(any(test, feature = "test-support"))]
1987    pub fn spawn_subscriber_count_for_test(&self) -> usize {
1988        self.spawn_events.subscriber_count()
1989    }
1990
1991    /// Record the launch nonce from a supervised spawn, replacing any prior nonce so
1992    /// a respawn invalidates stale consumer identities.
1993    pub fn set_spawn_nonce(&self, module_id: &str, nonce: String) {
1994        self.spawn_nonces
1995            .lock()
1996            .unwrap_or_else(|poisoned| poisoned.into_inner())
1997            .insert(module_id.to_string(), nonce);
1998    }
1999
2000    /// Record the launch nonce expected from the next HELLO for a reserved module,
2001    /// replacing any prior nonce (a respawn invalidates the previous one).
2002    pub fn set_reserved_nonce(&self, module_id: &str, nonce: String) {
2003        self.reserved_nonces
2004            .lock()
2005            .unwrap_or_else(|poisoned| poisoned.into_inner())
2006            .insert(module_id.to_string(), Some(nonce));
2007    }
2008
2009    /// Record namespace prefixes owned by a supervised module.
2010    pub fn set_reserved_prefixes(&self, owner_module_id: &str, prefixes: &[String]) {
2011        let mut owners = self
2012            .reserved_prefix_owners
2013            .lock()
2014            .unwrap_or_else(|poisoned| poisoned.into_inner());
2015        owners.retain(|_, owner| owner != owner_module_id);
2016        for prefix in prefixes {
2017            owners.insert(prefix.clone(), owner_module_id.to_string());
2018        }
2019    }
2020
2021    /// The launch nonce most recently minted for a module's spawn, if any.
2022    #[cfg(test)]
2023    pub(crate) fn spawn_nonce(&self, module_id: &str) -> Option<String> {
2024        self.spawn_nonces
2025            .lock()
2026            .unwrap_or_else(|poisoned| poisoned.into_inner())
2027            .get(module_id)
2028            .cloned()
2029    }
2030
2031    fn apply_identity_configuration(&self, spec: &ModuleSpec) {
2032        self.set_reserved_prefixes(&spec.module_id, &spec.reserved_prefixes);
2033        let spawn_nonce = self
2034            .spawn_nonces
2035            .lock()
2036            .unwrap_or_else(|poisoned| poisoned.into_inner())
2037            .get(&spec.module_id)
2038            .cloned();
2039        let mut reserved_nonces = self
2040            .reserved_nonces
2041            .lock()
2042            .unwrap_or_else(|poisoned| poisoned.into_inner());
2043        if spec.reserved {
2044            // `None` (no spawn nonce minted) is INSERTED, not skipped: a
2045            // reserved name whose module has never spawned has no legitimate
2046            // holder, and the entry's absence is what used to leave the name
2047            // open to the first claimant.
2048            reserved_nonces.insert(spec.module_id.clone(), spawn_nonce);
2049        }
2050        drop(reserved_nonces);
2051        // A later unreserved declaration must not silently unreserve an id that
2052        // was retained after its reserved configuration was removed. The explicit
2053        // release ceremony is the only operation that retires that gate.
2054        self.removal_tombstones
2055            .lock()
2056            .unwrap_or_else(|poisoned| poisoned.into_inner())
2057            .remove(&spec.module_id);
2058    }
2059
2060    /// Whether a HELLO claiming `module_id` is authorized. An exact reserved id is
2061    /// authorized only by its expected nonce; otherwise a matching reserved prefix
2062    /// is authorized by the owner module's current spawn nonce. Non-reserved ids
2063    /// with no matching prefix are always authorized.
2064    pub fn reserved_hello_authorized(&self, module_id: &str, presented: Option<&str>) -> bool {
2065        self.reserved_hello_rejection(module_id, presented)
2066            .is_none()
2067    }
2068
2069    pub(crate) fn reserved_hello_rejection(
2070        &self,
2071        module_id: &str,
2072        presented: Option<&str>,
2073    ) -> Option<ReservedHelloRejection> {
2074        let nonces = self
2075            .reserved_nonces
2076            .lock()
2077            .unwrap_or_else(|poisoned| poisoned.into_inner());
2078        if let Some(expected) = nonces.get(module_id) {
2079            // `None` = reserved with no legitimate holder: refuse every
2080            // presentation, because no process can hold a nonce that was never
2081            // minted. Only a real minted nonce admits, in constant time.
2082            let authorized = match expected {
2083                Some(expected) => {
2084                    presented.is_some_and(|p| constant_time_eq(expected.as_bytes(), p.as_bytes()))
2085                }
2086                None => false,
2087            };
2088            if authorized {
2089                return None;
2090            }
2091            return Some(ReservedHelloRejection::Exact {
2092                module_id: module_id.to_string(),
2093            });
2094        }
2095        drop(nonces);
2096
2097        let matched_prefix = self
2098            .reserved_prefix_owners
2099            .lock()
2100            .unwrap_or_else(|poisoned| poisoned.into_inner())
2101            .iter()
2102            .filter(|(prefix, _)| module_id.starts_with(prefix.as_str()))
2103            .max_by_key(|(prefix, _)| prefix.len())
2104            .map(|(prefix, owner)| (prefix.clone(), owner.clone()));
2105        let (prefix, owner_module_id) = matched_prefix?;
2106
2107        let authorized = presented.is_some_and(|presented| {
2108            self.spawn_nonces
2109                .lock()
2110                .unwrap_or_else(|poisoned| poisoned.into_inner())
2111                .get(&owner_module_id)
2112                .is_some_and(|expected| constant_time_eq(expected.as_bytes(), presented.as_bytes()))
2113                // While the owner is being swapped, children started by
2114                // either of its two processes hold that process's nonce.
2115                || self.swap_nonce_matches(&owner_module_id, presented)
2116        });
2117        if authorized {
2118            None
2119        } else {
2120            Some(ReservedHelloRejection::Prefix {
2121                prefix,
2122                owner_module_id,
2123            })
2124        }
2125    }
2126
2127    /// Whether a consumer connection proved it came from a daemon-spawned module.
2128    ///
2129    /// Absence of an expected spawn nonce is a hard failure: consumer_identity is
2130    /// accepted only for module ids the supervisor has spawned.
2131    pub fn spawned_consumer_authorized(&self, module_id: &str, presented: &str) -> bool {
2132        if presented.is_empty() {
2133            return false;
2134        }
2135        let nonces = self
2136            .spawn_nonces
2137            .lock()
2138            .unwrap_or_else(|poisoned| poisoned.into_inner());
2139        let current = nonces
2140            .get(module_id)
2141            .is_some_and(|expected| constant_time_eq(expected.as_bytes(), presented.as_bytes()));
2142        drop(nonces);
2143        // During a swap two processes of the module are alive, and a consumer
2144        // started by either one presents that process's nonce. Accepting only
2145        // the recorded one would fail the incumbent's consumers for the whole
2146        // overlap once cutover moves the record to the candidate.
2147        current || self.swap_nonce_matches(module_id, presented)
2148    }
2149
2150    /// Whether `presented` is either nonce of an open swap for `module_id`.
2151    fn swap_nonce_matches(&self, module_id: &str, presented: &str) -> bool {
2152        let swaps = self
2153            .swaps
2154            .lock()
2155            .unwrap_or_else(|poisoned| poisoned.into_inner());
2156        swaps.get(module_id).is_some_and(|swap| {
2157            constant_time_eq(swap.candidate_nonce.as_bytes(), presented.as_bytes())
2158                || swap.incumbent_nonce.as_deref().is_some_and(|incumbent| {
2159                    constant_time_eq(incumbent.as_bytes(), presented.as_bytes())
2160                })
2161        })
2162    }
2163
2164    /// Open a swap for `module_id` with the candidate's freshly minted nonce.
2165    /// Called before the candidate process exists.
2166    pub(crate) fn open_swap(&self, module_id: &str, candidate_nonce: String) {
2167        let incumbent_nonce = self
2168            .spawn_nonces
2169            .lock()
2170            .unwrap_or_else(|poisoned| poisoned.into_inner())
2171            .get(module_id)
2172            .cloned();
2173        self.swaps
2174            .lock()
2175            .unwrap_or_else(|poisoned| poisoned.into_inner())
2176            .insert(
2177                module_id.to_string(),
2178                OpenSwap {
2179                    candidate_nonce,
2180                    incumbent_nonce,
2181                    candidate_admitted: false,
2182                },
2183            );
2184    }
2185
2186    /// Close the swap for `module_id`, releasing whichever nonce is no longer
2187    /// the module's recorded one.
2188    pub(crate) fn close_swap(&self, module_id: &str) {
2189        self.swaps
2190            .lock()
2191            .unwrap_or_else(|poisoned| poisoned.into_inner())
2192            .remove(module_id);
2193    }
2194
2195    /// Install the observer told about swap promotions, replacing any earlier
2196    /// one.
2197    pub(crate) fn set_swap_promotion_observer(
2198        &self,
2199        observer: std::sync::Weak<dyn SwapPromotionObserver>,
2200    ) {
2201        *self
2202            .promotion_observer
2203            .0
2204            .lock()
2205            .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(observer);
2206    }
2207
2208    /// Tell the installed observer, if it is still alive, that a swap promoted
2209    /// `registration`.
2210    fn notify_swap_promoted(&self, registration: &crate::registry::ModuleRegistration) {
2211        let observer = self
2212            .promotion_observer
2213            .0
2214            .lock()
2215            .unwrap_or_else(|poisoned| poisoned.into_inner())
2216            .as_ref()
2217            .and_then(std::sync::Weak::upgrade);
2218        if let Some(observer) = observer {
2219            observer.swap_promoted(registration);
2220        }
2221    }
2222
2223    /// Whether a swap is open for `module_id`.
2224    pub(crate) fn swap_open(&self, module_id: &str) -> bool {
2225        self.swaps
2226            .lock()
2227            .unwrap_or_else(|poisoned| poisoned.into_inner())
2228            .contains_key(module_id)
2229    }
2230
2231    /// Make the candidate's nonce the module's recorded spawn nonce, as a plain
2232    /// respawn would, once cutover has made the candidate the module's process.
2233    /// The swap stays open so the incumbent's nonce keeps attesting until the
2234    /// incumbent has drained and exited.
2235    fn promote_swap_nonce(&self, module_id: &str, reserved: bool) {
2236        let candidate_nonce = self
2237            .swaps
2238            .lock()
2239            .unwrap_or_else(|poisoned| poisoned.into_inner())
2240            .get(module_id)
2241            .map(|swap| swap.candidate_nonce.clone());
2242        let Some(nonce) = candidate_nonce else {
2243            return;
2244        };
2245        self.set_spawn_nonce(module_id, nonce.clone());
2246        if reserved {
2247            self.set_reserved_nonce(module_id, nonce);
2248        }
2249    }
2250
2251    /// The swap gate for a HELLO claiming `module_id`.
2252    ///
2253    /// This runs BEFORE the reserved-module gate. A reserved module's candidate
2254    /// presents the candidate nonce, which the reserved gate (holding the
2255    /// incumbent's nonce) would refuse as `reserved_module` before swap
2256    /// admission was ever reached. And it applies to unreserved ids too: for an
2257    /// unreserved id the only thing that ever stopped a second process claiming
2258    /// a live id was the `duplicate_module_id` refusal, which is exactly the
2259    /// refusal a swap lifts for its candidate.
2260    ///
2261    /// The incumbent's own nonce falls through to the ordinary gates, which
2262    /// treat it as they always have (a live incumbent is refused as a
2263    /// duplicate). Anything else while a swap is open is refused, including an
2264    /// absent nonce.
2265    pub(crate) fn swap_hello_admission(
2266        &self,
2267        module_id: &str,
2268        presented: Option<&str>,
2269    ) -> SwapHelloAdmission {
2270        let swaps = self
2271            .swaps
2272            .lock()
2273            .unwrap_or_else(|poisoned| poisoned.into_inner());
2274        let Some(swap) = swaps.get(module_id) else {
2275            return SwapHelloAdmission::NotSwapping;
2276        };
2277        let Some(presented) = presented else {
2278            return SwapHelloAdmission::Refused;
2279        };
2280        if constant_time_eq(swap.candidate_nonce.as_bytes(), presented.as_bytes()) {
2281            return if swap.candidate_admitted {
2282                SwapHelloAdmission::Refused
2283            } else {
2284                SwapHelloAdmission::Candidate
2285            };
2286        }
2287        if swap
2288            .incumbent_nonce
2289            .as_deref()
2290            .is_some_and(|incumbent| constant_time_eq(incumbent.as_bytes(), presented.as_bytes()))
2291        {
2292            return SwapHelloAdmission::NotSwapping;
2293        }
2294        SwapHelloAdmission::Refused
2295    }
2296
2297    /// Record that the swap token has registered a candidate, so it admits no
2298    /// second HELLO.
2299    pub(crate) fn mark_swap_candidate_admitted(&self, module_id: &str) {
2300        if let Some(swap) = self
2301            .swaps
2302            .lock()
2303            .unwrap_or_else(|poisoned| poisoned.into_inner())
2304            .get_mut(module_id)
2305        {
2306            swap.candidate_admitted = true;
2307        }
2308    }
2309
2310    /// Test/support lookup for the current launch nonce of a supervised spawn.
2311    pub fn spawn_launch_nonce_for(&self, module_id: &str) -> Option<String> {
2312        self.spawn_nonces
2313            .lock()
2314            .unwrap_or_else(|poisoned| poisoned.into_inner())
2315            .get(module_id)
2316            .cloned()
2317    }
2318
2319    /// Test/support lookup for the HELLO-gating nonce of a reserved module.
2320    pub fn reserved_launch_nonce_for(&self, module_id: &str) -> Option<String> {
2321        self.reserved_nonces
2322            .lock()
2323            .unwrap_or_else(|poisoned| poisoned.into_inner())
2324            .get(module_id)
2325            .cloned()
2326            .flatten()
2327    }
2328
2329    pub fn insert(&self, module: SupervisedModule) -> Option<SupervisedModule> {
2330        // Normally already marked before the process was spawned; marking here
2331        // too keeps `configured_ids` a superset of the roster for any caller
2332        // that inserts a module directly.
2333        self.mark_configured(module.module_id());
2334        let mut modules = self
2335            .modules
2336            .lock()
2337            .unwrap_or_else(|poisoned| poisoned.into_inner());
2338        modules.insert(module.module_id().to_string(), module)
2339    }
2340
2341    /// Record that the supervisor has taken on `module_id`. Called before the
2342    /// module's first process is spawned, so that by the time that process can
2343    /// register, [`Self::is_configured`] already answers true.
2344    fn mark_configured(&self, module_id: &str) {
2345        self.configured_ids
2346            .lock()
2347            .unwrap_or_else(|poisoned| poisoned.into_inner())
2348            .insert(module_id.to_string());
2349    }
2350
2351    /// Undo [`Self::mark_configured`] for a module whose first spawn failed
2352    /// before it was ever put on the roster. A module already on the roster
2353    /// keeps its mark: only [`Self::retire`] takes a rostered module off.
2354    fn unmark_configured_unless_rostered(&self, module_id: &str) {
2355        let modules = self
2356            .modules
2357            .lock()
2358            .unwrap_or_else(|poisoned| poisoned.into_inner());
2359        if !modules.contains_key(module_id) {
2360            self.configured_ids
2361                .lock()
2362                .unwrap_or_else(|poisoned| poisoned.into_inner())
2363                .remove(module_id);
2364        }
2365    }
2366
2367    /// Whether `module_id` is a module this daemon supervises: on the roster,
2368    /// or about to be (its process is being spawned right now).
2369    ///
2370    /// This, not `get(..).is_some()`, is what "the owner is configured" means
2371    /// for scopes: a supervised module's process can register and sync before
2372    /// [`Self::get`] can return it, and in that window it is still a module
2373    /// that will sync, not one that never will.
2374    pub(crate) fn is_configured(&self, module_id: &str) -> bool {
2375        self.configured_ids
2376            .lock()
2377            .unwrap_or_else(|poisoned| poisoned.into_inner())
2378            .contains(module_id)
2379    }
2380
2381    pub fn get(&self, module_id: &str) -> Option<SupervisedModule> {
2382        let modules = self
2383            .modules
2384            .lock()
2385            .unwrap_or_else(|poisoned| poisoned.into_inner());
2386        modules.get(module_id).cloned()
2387    }
2388
2389    pub(crate) fn record_late_health_answer(
2390        &self,
2391        module_id: &str,
2392        latency_ms: u64,
2393    ) -> Result<bool, SuperviseError> {
2394        let Some(module) = self.get(module_id) else {
2395            return Ok(false);
2396        };
2397        update_snapshot(&module.inner.snapshot, Some(module_id), |state| {
2398            state.health.late_answer_count = state.health.late_answer_count.saturating_add(1);
2399            state.health.last_late_answer_latency_ms = Some(latency_ms);
2400            // A late answer is an answer: the module served the probe, just past
2401            // the deadline. Leaving the miss streak in place while logging
2402            // "proves the module is alive" is how a CPU-starved module that
2403            // answers every probe a few seconds late still marches to the
2404            // threshold and gets killed — the exact kill class `NoAnswer` is
2405            // excluded from `is_proof_of_death` to prevent. Slow-but-answering
2406            // is degradation, and degradation reports; it does not restart.
2407            state.health.consecutive_failures = 0;
2408        })?;
2409        Ok(true)
2410    }
2411
2412    /// Arm the one-shot marker for the module process that this caller
2413    /// deliberately initiated severance against. Generic connection teardown
2414    /// must not call this:
2415    /// a surviving process would otherwise retain an exemption for a later
2416    /// genuine crash.
2417    pub fn record_deliberate_severance(&self, module_id: &str) -> Result<bool, SuperviseError> {
2418        let Some(module) = self.get(module_id) else {
2419            return Ok(false);
2420        };
2421        let snapshot = lock_snapshot(&module.inner.snapshot)?;
2422        let Some((pid, start_time)) = snapshot.pid.zip(snapshot.process_start_time) else {
2423            return Ok(false);
2424        };
2425        drop(snapshot);
2426        module.record_deliberate_severance(ProcessIdentity { pid, start_time })
2427    }
2428
2429    pub fn list(&self) -> Vec<SupervisedModule> {
2430        let modules = self
2431            .modules
2432            .lock()
2433            .unwrap_or_else(|poisoned| poisoned.into_inner());
2434        let mut modules = modules.values().cloned().collect::<Vec<_>>();
2435        modules.sort_by(|left, right| left.module_id().cmp(right.module_id()));
2436        modules
2437    }
2438
2439    pub(crate) fn retire(&self, module_id: &str) -> Option<SupervisedModule> {
2440        self.activity_images
2441            .lock()
2442            .unwrap_or_else(|p| p.into_inner())
2443            .remove(module_id);
2444        self.spawn_nonces
2445            .lock()
2446            .unwrap_or_else(|poisoned| poisoned.into_inner())
2447            .remove(module_id);
2448        self.close_swap(module_id);
2449        let mut reserved_nonces = self
2450            .reserved_nonces
2451            .lock()
2452            .unwrap_or_else(|poisoned| poisoned.into_inner());
2453        if reserved_nonces.contains_key(module_id) {
2454            // The old nonce must die with the removed process, but the exact-id
2455            // gate remains until an operator explicitly releases it.
2456            reserved_nonces.insert(module_id.to_string(), None);
2457        }
2458        drop(reserved_nonces);
2459        self.reserved_prefix_owners
2460            .lock()
2461            .unwrap_or_else(|poisoned| poisoned.into_inner())
2462            .retain(|_, owner| owner != module_id);
2463        let removed = self
2464            .modules
2465            .lock()
2466            .unwrap_or_else(|poisoned| poisoned.into_inner())
2467            .remove(module_id);
2468        self.configured_ids
2469            .lock()
2470            .unwrap_or_else(|poisoned| poisoned.into_inner())
2471            .remove(module_id);
2472        removed
2473    }
2474
2475    /// Remember a module removed by a non-preview rescan so route.open can
2476    /// distinguish that intentional removal from an unknown id.
2477    pub(crate) fn record_rescan_removal(&self, module_id: &str) {
2478        self.removal_tombstones
2479            .lock()
2480            .unwrap_or_else(|poisoned| poisoned.into_inner())
2481            .insert(module_id.to_string(), unix_ms_now());
2482    }
2483
2484    /// Return how long ago a rescan removed this module in milliseconds.
2485    pub(crate) fn removal_tombstone_age_ms(&self, module_id: &str) -> Option<u64> {
2486        self.removal_tombstones
2487            .lock()
2488            .unwrap_or_else(|poisoned| poisoned.into_inner())
2489            .get(module_id)
2490            .copied()
2491            .map(|removed_at_ms| unix_ms_now().saturating_sub(removed_at_ms))
2492    }
2493
2494    /// Retire a reserved-id gate only after its module has left supervision.
2495    ///
2496    /// A retained gate has no live nonce (`None`), so releasing any other entry
2497    /// would weaken a currently configured or otherwise active reservation.
2498    pub(crate) fn release_retained_reserved_gate(&self, module_id: &str) -> bool {
2499        if self.get(module_id).is_some() {
2500            return false;
2501        }
2502        let mut reserved_nonces = self
2503            .reserved_nonces
2504            .lock()
2505            .unwrap_or_else(|poisoned| poisoned.into_inner());
2506        if !matches!(reserved_nonces.get(module_id), Some(None)) {
2507            return false;
2508        }
2509        reserved_nonces.remove(module_id);
2510        true
2511    }
2512
2513    pub(crate) fn operation_lock(&self) -> Arc<AsyncMutex<()>> {
2514        Arc::clone(&self.operation_lock)
2515    }
2516}
2517
2518/// Process supervisor for subc-owned singleton modules.
2519#[derive(Debug, Clone)]
2520pub struct Supervisor {
2521    registry: Arc<Registry>,
2522    restart_policy: RestartPolicy,
2523    drain_timeout: Duration,
2524    connection_file_path: Option<PathBuf>,
2525    capture_logs_dir: Option<PathBuf>,
2526    forwarding: Option<Arc<ForwardingTable>>,
2527    process_liveness: Arc<SupervisorProcessLiveness>,
2528    supervisor_handle: Option<SupervisorHandle>,
2529    health: HealthConfig,
2530    daemon_start_clock: crate::clock::StartClock,
2531    terminal_journal: Option<Arc<crate::terminal_journal::TerminalJournal>>,
2532    spawn_events: SpawnEventFeed,
2533    provenance_probe: ExecutableIdentityProbe,
2534    /// Every process spawned through this supervisor (and its clones) and not
2535    /// yet reaped, so daemon shutdown can end them.
2536    child_roster: ChildRoster,
2537    #[cfg(target_os = "linux")]
2538    cgroup_placement: Option<subc_cgroup::Placement>,
2539    #[cfg(test)]
2540    test_after_first_spawn: AfterFirstSpawnHook,
2541}
2542
2543/// Test-only hook run on the path that takes on a new module, right after its
2544/// first `spawn_child` returns (the process exists and could already be
2545/// registering) and before that process is handed to the module's supervise
2546/// loop and put on the roster. Lets a test observe what a fast child would see
2547/// in that window without racing a real one.
2548#[cfg(test)]
2549#[derive(Clone, Default)]
2550struct AfterFirstSpawnHook(Option<AfterFirstSpawnFn>);
2551
2552#[cfg(test)]
2553type AfterFirstSpawnFn = Arc<dyn Fn(&str) + Send + Sync>;
2554
2555#[cfg(test)]
2556impl fmt::Debug for AfterFirstSpawnHook {
2557    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2558        f.write_str("AfterFirstSpawnHook")
2559    }
2560}
2561
2562#[cfg(test)]
2563impl AfterFirstSpawnHook {
2564    fn run(&self, module_id: &str) {
2565        if let Some(hook) = &self.0 {
2566            hook(module_id);
2567        }
2568    }
2569}
2570
2571impl Supervisor {
2572    #[cfg(test)]
2573    pub(crate) fn new_for_test(registry: Arc<Registry>, policy: RestartPolicy) -> Self {
2574        let supervisor = Self::new(registry, policy);
2575        #[cfg(target_os = "macos")]
2576        let supervisor = supervisor.with_privacy_trampoline(test_privacy_trampoline());
2577        supervisor
2578    }
2579    /// Verify the trampoline once when configured. Missing private OS support
2580    /// refuses every macOS launch by name but does not stop the daemon's control
2581    /// server. Embedders must explicitly provide a binary with the subc-os hidden
2582    /// entry point; the library must not exec an arbitrary hosting program.
2583    pub fn with_privacy_trampoline(self, path: impl Into<PathBuf>) -> Self {
2584        let path = path.into();
2585        #[cfg(target_os = "macos")]
2586        {
2587            let result = probe_privacy_trampoline(&path).map(|()| path);
2588            if let Err(cause) = &result {
2589                error!(%cause, "macOS privacy identity unavailable; supervised launches will refuse");
2590            }
2591            self.child_roster.set_privacy_trampoline(result);
2592        }
2593        #[cfg(not(target_os = "macos"))]
2594        let _ = path;
2595        self
2596    }
2597    /// The first step of an announced daemon shutdown, before the notice and
2598    /// before any connection is closed.
2599    ///
2600    /// Sets the daemon-shutdown flag first: from here on no module is
2601    /// respawned (crash restart, operator restart, or swap), and every child
2602    /// exit is recorded as `daemon_shutdown` rather than as a crash, whether
2603    /// the module exits on the EOF this shutdown gives it or is signalled by a
2604    /// service manager that kills the whole cgroup. Then writes the journal's
2605    /// shutdown marker, which records the instant and closes this daemon
2606    /// incarnation's stretch of the journal.
2607    #[cfg(unix)]
2608    pub(crate) fn begin_daemon_shutdown(&self) {
2609        self.child_roster.close();
2610        if let Some(journal) = &self.terminal_journal {
2611            journal.stamp_shutdown();
2612        }
2613    }
2614
2615    /// Announce a cut while established connections can still carry replies.
2616    /// These budgets promise notice and a bounded wait, not child completion;
2617    /// they are local policy, not an estimate of launchd's unknown kill ceiling.
2618    #[cfg(unix)]
2619    pub(crate) async fn drain_for_daemon_shutdown(&self) -> Result<(), SuperviseError> {
2620        const NOTICE_BUDGET: Duration = Duration::from_millis(500);
2621        const DRAIN_BUDGET: Duration = Duration::from_secs(2);
2622        let Some(forwarding) = &self.forwarding else {
2623            return Ok(());
2624        };
2625        let modules = forwarding
2626            .begin_daemon_drain()
2627            .map_err(SuperviseError::Forwarding)?;
2628        let deadline_ms =
2629            unix_ms_now().saturating_add((NOTICE_BUDGET + DRAIN_BUDGET).as_millis() as u64);
2630        let mut notices = tokio::task::JoinSet::new();
2631        let mut drains = Vec::new();
2632        for (module_id, endpoint) in modules {
2633            let Some(target) = forwarding
2634                .begin_endpoint_drain(endpoint, RouteCloseReason::Restart)
2635                .map_err(SuperviseError::Forwarding)?
2636            else {
2637                continue;
2638            };
2639            let routes = forwarding
2640                .endpoint_routes(target.endpoint)
2641                .map_err(SuperviseError::Forwarding)?;
2642            // Restart allows deployed consumers to reopen after the new daemon
2643            // appears. The wire reason stays `restart`; what tells a daemon cut
2644            // apart from a module restart afterwards is the terminal record
2645            // itself, whose disposition is `daemon_shutdown` for every exit
2646            // observed once `begin_daemon_shutdown` has run.
2647            let command = serde_json::to_vec(&ModuleControlCommand::Draining {
2648                reason: RouteCloseReason::Restart,
2649                deadline_ms,
2650            })
2651            .expect("module draining serializes");
2652            let mut recipients = vec![(target.sink.clone(), target.negotiated_ver, command)];
2653            let mut clients: Vec<(crate::forwarding::GoodbyeTarget, Vec<u16>)> = Vec::new();
2654            for route in routes {
2655                let client = route.goodbye_target;
2656                if let Some((_, channels)) = clients
2657                    .iter_mut()
2658                    .find(|(existing, _)| existing.connection_id == client.connection_id)
2659                {
2660                    channels.push(client.channel);
2661                } else {
2662                    let channel = client.channel;
2663                    clients.push((client, vec![channel]));
2664                }
2665            }
2666            for (client, mut channels) in clients {
2667                channels.sort_unstable();
2668                channels.dedup();
2669                let closing = serde_json::to_vec(&ClientControlPush::RouteClosing {
2670                    module_id: module_id.clone(),
2671                    channels,
2672                    reason: RouteCloseReason::Restart,
2673                })
2674                .expect("route closing serializes");
2675                recipients.push((client.sink, client.negotiated_ver, closing));
2676            }
2677            for (sink, version, body) in recipients {
2678                notices.spawn(async move {
2679                    let frame = Frame::build_with_version(
2680                        version,
2681                        FrameType::Push,
2682                        control_flags(),
2683                        0,
2684                        0,
2685                        0,
2686                        body,
2687                    )
2688                    .expect("bounded lifecycle notice frame builds");
2689                    sink.send_flushed(frame).await
2690                });
2691            }
2692            let gauges = declared_busy_gauges(&self.registry, &module_id)?;
2693            drains.push((module_id, target.endpoint, gauges));
2694        }
2695        // A quiet forwarding table is not proof that queued notices reached the
2696        // socket. Wait for writer flush acknowledgements before testing quiescence.
2697        let notice_deadline = Instant::now() + NOTICE_BUDGET;
2698        while let Ok(Some(result)) = timeout_at(notice_deadline, notices.join_next()).await {
2699            if !matches!(result, Ok(Ok(()))) {
2700                warn!(?result, "daemon shutdown notice delivery failed");
2701            }
2702        }
2703        notices.abort_all();
2704        let deadline = Instant::now() + DRAIN_BUDGET;
2705        let mut waits = tokio::task::JoinSet::new();
2706        for (module_id, endpoint, gauges) in drains {
2707            let forwarding = Arc::clone(forwarding);
2708            let mut runtime = self.runtime_config();
2709            runtime.health.cadence = Duration::from_millis(100);
2710            waits.spawn(async move {
2711                let wait_result = wait_for_forwarding_quiescence(
2712                    &forwarding,
2713                    &module_id,
2714                    &runtime,
2715                    endpoint,
2716                    deadline,
2717                    &gauges,
2718                    DrainScope::Endpoint(endpoint),
2719                )
2720                .await;
2721                // Store the measurement before the task returns: quiescence
2722                // may be reached just as the deadline stops the join loop from
2723                // collecting results. Unfinished or failed waits report false.
2724                forwarding
2725                    .record_daemon_drain_result(
2726                        endpoint,
2727                        drained_after_quiescence_wait(&wait_result),
2728                    )
2729                    .map_err(SuperviseError::Forwarding)?;
2730                wait_result
2731            });
2732        }
2733        while let Ok(Some(result)) = timeout_at(deadline, waits.join_next()).await {
2734            if !matches!(result, Ok(Ok(true))) {
2735                warn!(?result, "daemon shutdown drain did not reach quiescence");
2736            }
2737        }
2738        Ok(())
2739    }
2740
2741    /// The last step of an announced daemon shutdown, after the notice and the
2742    /// drain: send every registered module a module GOODBYE, the same planned
2743    /// stop signal `ck module stop` gives, then close every connection so each
2744    /// subc module sees EOF and starts its own teardown, then end every
2745    /// supervised child that has not exited
2746    /// by its own deadline (its drain budget, capped). Modules lead their own
2747    /// process groups, so a
2748    /// service manager's group kill no longer reaches them; without this a
2749    /// child that does not stop on EOF (every `protocol: "none"` child, which
2750    /// has no connection) would outlive the daemon. Every wait is bounded (see
2751    /// `child_roster`), and `escalate` resolving (a second SIGTERM) cuts them.
2752    #[cfg(unix)]
2753    pub(crate) async fn end_children_for_daemon_shutdown(
2754        &self,
2755        already_escalated: bool,
2756        escalate: impl std::future::Future<Output = ()>,
2757    ) {
2758        tokio::pin!(escalate);
2759        let mut escalated = already_escalated;
2760        if let Some(forwarding) = &self.forwarding {
2761            let reason = CloseReason::new(
2762                "daemon_shutdown",
2763                "the daemon is exiting after its shutdown notice and drain",
2764            );
2765            if escalated {
2766                // The operator asked to stop waiting: queue the GOODBYEs but
2767                // do not wait for them to be written.
2768                send_module_goodbyes_for_daemon_shutdown(forwarding, &reason, false).await;
2769            } else {
2770                tokio::select! {
2771                    biased;
2772                    _ = escalate.as_mut() => {
2773                        info!("second SIGTERM: abandoning module GOODBYE delivery");
2774                        escalated = true;
2775                    }
2776                    _ = send_module_goodbyes_for_daemon_shutdown(forwarding, &reason, true) => {}
2777                }
2778            }
2779            let closed = forwarding.close_all_connections(&reason);
2780            debug!(closed, "closed established connections for daemon shutdown");
2781        }
2782        // If the second SIGTERM arrived during GOODBYE delivery, `escalate` has
2783        // already completed and must not be polled again; the child shutdown
2784        // wait is told it is escalated and gets a future that never fires.
2785        let escalated_here = escalated && !already_escalated;
2786        let remaining_escalate = async move {
2787            if escalated_here {
2788                std::future::pending::<()>().await;
2789            } else {
2790                escalate.await;
2791            }
2792        };
2793        crate::child_roster::end_children_for_daemon_shutdown(
2794            &self.child_roster,
2795            escalated,
2796            remaining_escalate,
2797        )
2798        .await;
2799    }
2800
2801    pub fn new(registry: Arc<Registry>, restart_policy: RestartPolicy) -> Self {
2802        Self {
2803            registry,
2804            restart_policy,
2805            drain_timeout: DEFAULT_DRAIN_TIMEOUT,
2806            connection_file_path: None,
2807            capture_logs_dir: None,
2808            forwarding: None,
2809            process_liveness: Arc::new(SupervisorProcessLiveness::default()),
2810            supervisor_handle: None,
2811            health: HealthConfig::default(),
2812            daemon_start_clock: crate::clock::StartClock::capture(),
2813            terminal_journal: None,
2814            spawn_events: SpawnEventFeed::default(),
2815            provenance_probe: ExecutableIdentityProbe::default(),
2816            child_roster: ChildRoster::default(),
2817            #[cfg(target_os = "linux")]
2818            cgroup_placement: None,
2819            #[cfg(test)]
2820            test_after_first_spawn: AfterFirstSpawnHook::default(),
2821        }
2822    }
2823
2824    pub fn with_drain_timeout(mut self, drain_timeout: Duration) -> Self {
2825        self.drain_timeout = drain_timeout;
2826        self
2827    }
2828
2829    pub fn with_process_liveness(
2830        mut self,
2831        process_liveness: Arc<SupervisorProcessLiveness>,
2832    ) -> Self {
2833        self.process_liveness = process_liveness;
2834        self
2835    }
2836
2837    pub fn with_connection_file_path(mut self, connection_file_path: impl Into<PathBuf>) -> Self {
2838        self.connection_file_path = Some(connection_file_path.into());
2839        self
2840    }
2841
2842    /// Enables daemon-owned capture files for supervised stdout and stderr.
2843    pub fn with_capture_logs_dir(mut self, logs_dir: impl Into<PathBuf>) -> Self {
2844        self.capture_logs_dir = Some(logs_dir.into());
2845        self
2846    }
2847
2848    /// Names this daemon lifetime in spawn events, independently of whether a
2849    /// terminal journal is configured.
2850    pub fn with_daemon_incarnation(self, daemon_incarnation: String) -> Self {
2851        // A millisecond start stamp can repeat after clock rollback or a rapid
2852        // restart. Use the connection file's random daemon_id instead: it already
2853        // identifies this daemon lifetime independently of the wall clock.
2854        self.spawn_events.configure_incarnation(daemon_incarnation);
2855        self
2856    }
2857
2858    /// Enables best-effort history shared by every supervised module. Without
2859    /// it, terminal history is kept only in each module's in-memory ring.
2860    pub fn with_terminal_journal(self, path: PathBuf, daemon_incarnation: String) -> Self {
2861        let mut this = self.with_daemon_incarnation(daemon_incarnation.clone());
2862        this.terminal_journal = Some(Arc::new(crate::terminal_journal::TerminalJournal::open(
2863            path,
2864            daemon_incarnation,
2865        )));
2866        this
2867    }
2868
2869    pub fn with_forwarding(mut self, forwarding: Arc<ForwardingTable>) -> Self {
2870        self.forwarding = Some(forwarding);
2871        self
2872    }
2873
2874    pub fn with_handle(mut self, supervisor_handle: SupervisorHandle) -> Self {
2875        self.spawn_events = supervisor_handle.spawn_events.clone();
2876        self.supervisor_handle = Some(supervisor_handle);
2877        self
2878    }
2879
2880    pub fn with_health_config(mut self, health: HealthConfig) -> Self {
2881        self.health = health;
2882        self
2883    }
2884
2885    /// Keeps a record of every live child at `path`, rewritten on each spawn and
2886    /// reap, for the orphan sweep a later daemon runs at boot. Without it no
2887    /// record is kept.
2888    pub fn with_live_children_record(self, path: impl Into<PathBuf>) -> Self {
2889        self.child_roster.record_to(path.into());
2890        self
2891    }
2892
2893    #[cfg(target_os = "linux")]
2894    pub fn with_cgroup_placement(
2895        mut self,
2896        cgroup_placement: Option<subc_cgroup::Placement>,
2897    ) -> Self {
2898        self.cgroup_placement = cgroup_placement;
2899        self
2900    }
2901
2902    /// Spawn `spec.program` and start monitoring it.
2903    ///
2904    /// The child is expected to parse `--subc <connection-file-path>`, read the
2905    /// TCP+key connection file, authenticate to the already-running listener, and
2906    /// register with channel-0 `HELLO` using `spec.module_id` as its manifest id.
2907    pub fn spawn(&self, spec: ModuleSpec) -> Result<SupervisedModule, SuperviseError> {
2908        validate_spec(&spec)?;
2909        self.establish_identity(&spec);
2910
2911        let runtime = self.runtime_config();
2912        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
2913        let activity_spawn = runtime.begin_activity_spawn(&spec.module_id);
2914        let spawned = spawn_child(
2915            &spec,
2916            runtime.connection_file_path.as_deref(),
2917            self.supervisor_handle.as_ref(),
2918            &runtime.stderr_ring,
2919            runtime.capture_logs_dir.as_deref(),
2920            &runtime.child_roster,
2921            #[cfg(target_os = "linux")]
2922            runtime.cgroup_placement.as_ref(),
2923        );
2924        #[cfg(test)]
2925        self.test_after_first_spawn.run(&spec.module_id);
2926        let child = match spawned {
2927            Ok(child) => child,
2928            Err(err) => {
2929                // Unlike the configured paths, a failed `spawn` leaves nothing
2930                // on the roster, so the module must not stay marked configured.
2931                self.abandon_unrostered(&spec.module_id);
2932                return Err(err);
2933            }
2934        };
2935        self.mark_first_process_running(&spec, &runtime, &snapshot, &child)?;
2936        if let Some(spawn) = activity_spawn {
2937            spawn.complete(&snapshot);
2938        }
2939
2940        Ok(self.supervised_module(spec, runtime, snapshot, Some(child)))
2941    }
2942
2943    /// Make `spec`'s module count as configured, with its identity gates
2944    /// (reserved id, reserved prefixes) in place, BEFORE any process of it
2945    /// exists.
2946    ///
2947    /// Every path that takes on a new module calls this before `spawn_child`.
2948    /// The order is the point: the child can connect, register, sync its
2949    /// scopes and ask about them as soon as it is spawned, and the module is
2950    /// only put on the roster after `spawn_child` returns. Were the mark set
2951    /// with the roster entry, a fast child would see its own owner reported
2952    /// as not configured, and a scoped `route.open` in that window would be
2953    /// refused as terminal `scope_not_live` ("will never sync") instead of
2954    /// retryable `scope_not_synced`.
2955    fn establish_identity(&self, spec: &ModuleSpec) {
2956        if let Some(supervisor_handle) = &self.supervisor_handle {
2957            supervisor_handle.apply_identity_configuration(spec);
2958            supervisor_handle.mark_configured(&spec.module_id);
2959        }
2960    }
2961
2962    /// Take back [`Self::establish_identity`]'s configured mark when the
2963    /// module will not be put on the roster after all.
2964    fn abandon_unrostered(&self, module_id: &str) {
2965        if let Some(supervisor_handle) = &self.supervisor_handle {
2966            supervisor_handle.unmark_configured_unless_rostered(module_id);
2967        }
2968    }
2969
2970    /// Record a freshly spawned first process as running. On failure the
2971    /// module never reaches the roster, so its configured mark is taken back.
2972    fn mark_first_process_running(
2973        &self,
2974        spec: &ModuleSpec,
2975        runtime: &SupervisorRuntimeConfig,
2976        snapshot: &SharedSnapshot,
2977        child: &SupervisedChild,
2978    ) -> Result<(), SuperviseError> {
2979        if let Err(err) = set_running(snapshot, child, &spec.module_id, &runtime.spawn_events) {
2980            self.abandon_unrostered(&spec.module_id);
2981            return Err(err);
2982        }
2983        self.process_liveness
2984            .track(spec.module_id.clone(), Arc::clone(snapshot));
2985        Ok(())
2986    }
2987
2988    /// Start supervising a module declared in daemon configuration.
2989    ///
2990    /// Unlike [`Self::spawn`], this records disabled modules and immediate spawn
2991    /// failures in the supervisor handle so operator-facing `supervisor.list`
2992    /// reflects every configured module while daemon startup continues.
2993    pub fn supervise_configured(
2994        &self,
2995        spec: ModuleSpec,
2996        enabled: bool,
2997    ) -> Result<SupervisedModule, SuperviseError> {
2998        validate_spec(&spec)?;
2999        self.establish_identity(&spec);
3000
3001        let runtime = self.runtime_config();
3002        if !enabled {
3003            let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::disabled()));
3004            return Ok(self.supervised_module(spec, runtime, snapshot, None));
3005        }
3006
3007        #[cfg(target_os = "linux")]
3008        if let Some(pid) = runtime.child_roster.previous_instance_pid(&spec.module_id) {
3009            let mut snapshot = SupervisorSnapshot::starting();
3010            snapshot.waiting_for_previous_pid = Some(pid);
3011            return Ok(self.supervised_module(spec, runtime, Arc::new(Mutex::new(snapshot)), None));
3012        }
3013
3014        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
3015        let activity_spawn = runtime.begin_activity_spawn(&spec.module_id);
3016        let spawned = spawn_child(
3017            &spec,
3018            runtime.connection_file_path.as_deref(),
3019            self.supervisor_handle.as_ref(),
3020            &runtime.stderr_ring,
3021            runtime.capture_logs_dir.as_deref(),
3022            &runtime.child_roster,
3023            #[cfg(target_os = "linux")]
3024            runtime.cgroup_placement.as_ref(),
3025        );
3026        #[cfg(test)]
3027        self.test_after_first_spawn.run(&spec.module_id);
3028        match spawned {
3029            Ok(child) => {
3030                self.mark_first_process_running(&spec, &runtime, &snapshot, &child)?;
3031                if let Some(spawn) = activity_spawn {
3032                    spawn.complete(&snapshot);
3033                }
3034                Ok(self.supervised_module(spec, runtime, snapshot, Some(child)))
3035            }
3036            Err(err) => {
3037                error!(
3038                    module_id = %spec.module_id,
3039                    program = %spec.program.display(),
3040                    error = %err,
3041                    "configured module failed to spawn; marking failed and continuing"
3042                );
3043                let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::failed()));
3044                Ok(self.supervised_module(spec, runtime, snapshot, None))
3045            }
3046        }
3047    }
3048
3049    /// Supervise a configured module with its own health, drain, and crash
3050    /// budget. The restart policy is per-module because the config file is:
3051    /// `modules.<id>.restart` resolves to a full policy at parse time, and a
3052    /// module that is expensive to restart should not be forced onto the same
3053    /// budget as one that is cheap.
3054    pub fn supervise_configured_with_health(
3055        &self,
3056        spec: ModuleSpec,
3057        enabled: bool,
3058        health: HealthConfig,
3059        drain_timeout_ms: Option<u64>,
3060        restart_policy: RestartPolicy,
3061    ) -> Result<SupervisedModule, SuperviseError> {
3062        self.supervise_configured_requested(
3063            spec,
3064            enabled,
3065            health,
3066            drain_timeout_ms,
3067            restart_policy,
3068            None,
3069        )
3070    }
3071
3072    pub(crate) fn supervise_configured_requested(
3073        &self,
3074        spec: ModuleSpec,
3075        enabled: bool,
3076        health: HealthConfig,
3077        drain_timeout_ms: Option<u64>,
3078        restart_policy: RestartPolicy,
3079        requester: Option<Principal>,
3080    ) -> Result<SupervisedModule, SuperviseError> {
3081        validate_spec(&spec)?;
3082        self.establish_identity(&spec);
3083
3084        let mut runtime = self.runtime_config();
3085        runtime.start_requester = requester;
3086        runtime.health = health.clone();
3087        runtime.restart_policy = restart_policy;
3088        if let Some(ms) = drain_timeout_ms {
3089            runtime.drain_timeout = Duration::from_millis(ms);
3090            *runtime
3091                .effective_drain_timeout
3092                .lock()
3093                .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.drain_timeout;
3094        }
3095        if !enabled {
3096            let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::disabled()));
3097            return Ok(self.supervised_module(spec, runtime, snapshot, None));
3098        }
3099
3100        #[cfg(target_os = "linux")]
3101        if let Some(pid) = runtime.child_roster.previous_instance_pid(&spec.module_id) {
3102            let mut snapshot = SupervisorSnapshot::starting();
3103            snapshot.waiting_for_previous_pid = Some(pid);
3104            return Ok(self.supervised_module(spec, runtime, Arc::new(Mutex::new(snapshot)), None));
3105        }
3106
3107        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
3108        let activity_spawn = runtime.begin_activity_spawn(&spec.module_id);
3109        let spawned = spawn_child(
3110            &spec,
3111            runtime.connection_file_path.as_deref(),
3112            self.supervisor_handle.as_ref(),
3113            &runtime.stderr_ring,
3114            runtime.capture_logs_dir.as_deref(),
3115            &runtime.child_roster,
3116            #[cfg(target_os = "linux")]
3117            runtime.cgroup_placement.as_ref(),
3118        );
3119        #[cfg(test)]
3120        self.test_after_first_spawn.run(&spec.module_id);
3121        match spawned {
3122            Ok(child) => {
3123                self.mark_first_process_running(&spec, &runtime, &snapshot, &child)?;
3124                if let Some(spawn) = activity_spawn {
3125                    spawn.complete(&snapshot);
3126                }
3127                Ok(self.supervised_module(spec, runtime, snapshot, Some(child)))
3128            }
3129            Err(err) => {
3130                if health.critical {
3131                    error!(
3132                        module_id = %spec.module_id,
3133                        program = %spec.program.display(),
3134                        error = %err,
3135                        "critical configured module failed to spawn; marking failed and alerting"
3136                    );
3137                } else {
3138                    error!(
3139                        module_id = %spec.module_id,
3140                        program = %spec.program.display(),
3141                        error = %err,
3142                        "configured module failed to spawn; marking failed and continuing"
3143                    );
3144                }
3145                let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::failed()));
3146                Ok(self.supervised_module(spec, runtime, snapshot, None))
3147            }
3148        }
3149    }
3150
3151    fn runtime_config(&self) -> SupervisorRuntimeConfig {
3152        let effective_drain_timeout = Arc::new(Mutex::new(self.drain_timeout));
3153        SupervisorRuntimeConfig {
3154            start_requester: None,
3155            pending_activity_restart: false,
3156            provenance_probe: self.provenance_probe.clone(),
3157            scheduled_respawn: Arc::default(),
3158            deferred_reload_reply: Arc::default(),
3159            restart_policy: self.restart_policy,
3160            drain_timeout: self.drain_timeout,
3161            // Shared with this module's roster copy: daemon shutdown waits on
3162            // each child for the module's own drain budget, as resolved now.
3163            child_roster: self
3164                .child_roster
3165                .for_module(Arc::clone(&effective_drain_timeout)),
3166            effective_drain_timeout,
3167            default_drain_timeout: self.drain_timeout,
3168            health: self.health.clone(),
3169            connection_file_path: self.connection_file_path.clone(),
3170            capture_logs_dir: self.capture_logs_dir.clone(),
3171            forwarding: self.forwarding.clone(),
3172            supervisor_handle: self.supervisor_handle.clone(),
3173            stderr_ring: Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default()))),
3174            terminal_ring: Arc::new(Mutex::new(
3175                TerminalRing::new(
3176                    TerminalRingConfig::default(),
3177                    self.daemon_start_clock.started_at_ms(),
3178                )
3179                .with_start_clock(self.daemon_start_clock)
3180                .with_journal(self.terminal_journal.clone())
3181                .with_daemon_shutdown(self.child_roster.shutdown_flag()),
3182            )),
3183            spawn_events: self.spawn_events.clone(),
3184            #[cfg(target_os = "linux")]
3185            cgroup_placement: self.cgroup_placement.clone(),
3186            #[cfg(test)]
3187            test_seed_stale_facts_before_enable_spawn: false,
3188            #[cfg(test)]
3189            test_reload_exit_record_gate: None,
3190        }
3191    }
3192
3193    fn supervised_module(
3194        &self,
3195        spec: ModuleSpec,
3196        runtime: SupervisorRuntimeConfig,
3197        snapshot: SharedSnapshot,
3198        child: Option<SupervisedChild>,
3199    ) -> SupervisedModule {
3200        let configuration = Arc::new(Mutex::new(SupervisedConfiguration {
3201            spec: spec.clone(),
3202            health: runtime.health.clone(),
3203        }));
3204        let stderr_ring = Arc::clone(&runtime.stderr_ring);
3205        let terminal_ring = Arc::clone(&runtime.terminal_ring);
3206        // The module's OWN policy, which may be its per-module config rather than
3207        // the supervisor-wide one; status must report the budget the supervise
3208        // loop actually enforces.
3209        let restart_policy = runtime.restart_policy;
3210        let effective_drain_timeout = Arc::clone(&runtime.effective_drain_timeout);
3211        let (tx, rx) = mpsc::channel(4);
3212        let monitor = tokio::spawn(supervise_loop(
3213            spec.clone(),
3214            runtime,
3215            Arc::clone(&self.registry),
3216            Arc::clone(&self.process_liveness),
3217            Arc::clone(&snapshot),
3218            child,
3219            rx,
3220        ));
3221
3222        let module_id = spec.module_id.clone();
3223        let module = SupervisedModule {
3224            inner: Arc::new(SupervisedModuleInner {
3225                module_id: module_id.clone(),
3226                registry: Arc::clone(&self.registry),
3227                snapshot,
3228                configuration,
3229                stderr_ring,
3230                terminal_ring,
3231                commands: tx,
3232                monitor: Mutex::new(Some(monitor)),
3233                restart_policy,
3234                effective_drain_timeout,
3235                provenance_probe: self.provenance_probe.clone(),
3236            }),
3237        };
3238        // The identity gates and the configured mark were set by
3239        // `establish_identity` before any process was spawned; only the roster
3240        // entry waits for the module handle, which needs the spawned child.
3241        if let Some(supervisor_handle) = &self.supervisor_handle {
3242            supervisor_handle.insert(module.clone());
3243        }
3244        module
3245    }
3246}
3247
3248impl Default for Supervisor {
3249    fn default() -> Self {
3250        Self::new(Arc::new(Registry::default()), RestartPolicy::default())
3251    }
3252}
3253
3254/// Handle to one supervised child process.
3255#[derive(Clone)]
3256pub struct SupervisedModule {
3257    inner: Arc<SupervisedModuleInner>,
3258}
3259
3260struct SupervisedModuleInner {
3261    module_id: String,
3262    registry: Arc<Registry>,
3263    snapshot: SharedSnapshot,
3264    configuration: Arc<Mutex<SupervisedConfiguration>>,
3265    stderr_ring: Arc<Mutex<StderrRing>>,
3266    terminal_ring: Arc<Mutex<TerminalRing>>,
3267    commands: mpsc::Sender<SupervisorCommand>,
3268    monitor: Mutex<Option<JoinHandle<()>>>,
3269    /// Copied from the supervisor's runtime config at spawn so `status()` can
3270    /// report the restart budget without reaching back into the supervisor. The
3271    /// policy is fixed for the process's lifetime, so a copy cannot drift.
3272    restart_policy: RestartPolicy,
3273    effective_drain_timeout: Arc<Mutex<Duration>>,
3274    provenance_probe: ExecutableIdentityProbe,
3275}
3276
3277impl fmt::Debug for SupervisedModule {
3278    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3279        f.debug_struct("SupervisedModule")
3280            .field("module_id", &self.inner.module_id)
3281            .field("status", &self.status())
3282            .finish_non_exhaustive()
3283    }
3284}
3285
3286impl SupervisedModule {
3287    pub fn module_id(&self) -> &str {
3288        &self.inner.module_id
3289    }
3290
3291    /// Test-only: put one probe miss on the streak, the way
3292    /// `handle_health_probe_failure` does, so tests can assert what a later
3293    /// event does to the streak without driving the whole probe loop.
3294    #[cfg(test)]
3295    pub(crate) fn record_health_probe_failure_for_test(
3296        &self,
3297        detail: &str,
3298    ) -> Result<(), SuperviseError> {
3299        update_snapshot(&self.inner.snapshot, Some(&self.inner.module_id), |state| {
3300            state.health.consecutive_failures = state.health.consecutive_failures.saturating_add(1);
3301            state.health.detail = Some(detail.to_string());
3302        })
3303    }
3304
3305    pub fn state(&self) -> Result<ModuleState, SuperviseError> {
3306        Ok(lock_snapshot(&self.inner.snapshot)?.state)
3307    }
3308
3309    pub(crate) fn registration_end_reason(
3310        &self,
3311    ) -> Result<Option<RegistrationEndReason>, SuperviseError> {
3312        let snapshot = lock_snapshot(&self.inner.snapshot)?;
3313        Ok(match snapshot.state {
3314            ModuleState::Draining if snapshot.draining_to_replace => {
3315                Some(RegistrationEndReason::SupervisorRestart)
3316            }
3317            ModuleState::Draining => Some(RegistrationEndReason::SupervisorStop),
3318            ModuleState::Restarting if snapshot.draining_to_replace => {
3319                Some(RegistrationEndReason::SupervisorRestart)
3320            }
3321            _ => None,
3322        })
3323    }
3324
3325    /// The module's retained stderr, newest lines last.
3326    ///
3327    /// Deliberately NOT on [`Self::status`]: a bounded tail is kilobytes per
3328    /// module, `supervisor.list` renders every module, and putting it in the
3329    /// shared snapshot would make each status read carry a payload almost nobody
3330    /// asked for. Callers that want the text ask for it.
3331    pub fn stderr_tail(
3332        &self,
3333        max_lines: Option<usize>,
3334        max_bytes: Option<usize>,
3335    ) -> StderrTailSnapshot {
3336        self.inner
3337            .stderr_ring
3338            .lock()
3339            .unwrap_or_else(|poisoned| poisoned.into_inner())
3340            .snapshot(max_lines, max_bytes)
3341    }
3342
3343    /// The module's bounded terminal history, oldest retained exit first.
3344    ///
3345    /// The daemon-start stamp distinguishes a quiet supervisor from a replacement
3346    /// daemon whose in-memory history was necessarily reset.
3347    pub fn terminal_history(&self) -> TerminalHistorySnapshot {
3348        self.inner
3349            .terminal_ring
3350            .lock()
3351            .unwrap_or_else(|poisoned| poisoned.into_inner())
3352            .snapshot()
3353    }
3354
3355    /// Retained observations from the current ring and all journal generations.
3356    ///
3357    /// Blocking: this reads the journal files. Async callers use
3358    /// [`Self::read_durable_terminal_history`].
3359    pub fn durable_terminal_history(&self) -> subc_control::TerminalHistory {
3360        durable_terminal_history_of(&self.inner.terminal_ring, &self.inner.module_id)
3361    }
3362
3363    /// [`Self::durable_terminal_history`] on a blocking thread, so the journal
3364    /// read (up to every retained generation) never occupies a runtime worker.
3365    /// Fails only if the blocking task could not finish (runtime shutdown or a
3366    /// panic in the read).
3367    pub(crate) async fn read_durable_terminal_history(
3368        &self,
3369    ) -> Result<subc_control::TerminalHistory, tokio::task::JoinError> {
3370        let terminal_ring = Arc::clone(&self.inner.terminal_ring);
3371        let module_id = self.inner.module_id.clone();
3372        tokio::task::spawn_blocking(move || durable_terminal_history_of(&terminal_ring, &module_id))
3373            .await
3374    }
3375
3376    pub fn status(&self) -> Result<ModuleStatus, SuperviseError> {
3377        self.status_with_snapshot_lock(&self.inner.snapshot, None)
3378            .map(|(status, _)| status)
3379    }
3380
3381    pub(crate) fn record_deliberate_severance(
3382        &self,
3383        identity: ProcessIdentity,
3384    ) -> Result<bool, SuperviseError> {
3385        let mut snapshot = lock_snapshot(&self.inner.snapshot)?;
3386        if snapshot.pid != Some(identity.pid)
3387            || snapshot.process_start_time != Some(identity.start_time)
3388        {
3389            return Ok(false);
3390        }
3391        snapshot.deliberate_severance = Some(identity);
3392        Ok(true)
3393    }
3394
3395    /// Read status for a channel-0 renderer and report a contended snapshot lock.
3396    ///
3397    /// Internal supervision callers use [`Self::status`] so writer-side machinery
3398    /// does not produce reader-observability logs.
3399    pub(crate) fn status_for_control(
3400        &self,
3401        caller: &'static str,
3402    ) -> Result<ModuleStatus, SuperviseError> {
3403        self.status_with_snapshot_lock(&self.inner.snapshot, Some(caller))
3404            .map(|(status, _)| status)
3405    }
3406
3407    fn status_with_snapshot_lock(
3408        &self,
3409        snapshot: &SharedSnapshot,
3410        caller: Option<&'static str>,
3411    ) -> Result<(ModuleStatus, Option<SpawnedFileIdentity>), SuperviseError> {
3412        let mut guard = match caller {
3413            Some(caller) => lock_snapshot_for_control(snapshot, &self.inner.module_id, caller)?,
3414            None => lock_snapshot(snapshot)?,
3415        };
3416        // Read the budget through the pruning path so a reader sees the same
3417        // in-window count the restart decision would use, not a stale total.
3418        let restart_count =
3419            guard.crash_restarts_in_window(self.inner.restart_policy.window, Instant::now());
3420        let snapshot = guard.clone();
3421        drop(guard);
3422        let drain_timeout = *self.inner.effective_drain_timeout.lock().map_err(|_| {
3423            SuperviseError::StatePoisoned {
3424                module_id: Some(self.inner.module_id.clone()),
3425            }
3426        })?;
3427        let registration_active = self
3428            .inner
3429            .registry
3430            .get_module(&self.inner.module_id)
3431            .map_err(SuperviseError::Registry)?
3432            .is_some();
3433        let protocol = snapshot
3434            .spawned_protocol
3435            .unwrap_or(self.declared_protocol()?);
3436        let running_process =
3437            snapshot.enabled && snapshot.state == ModuleState::Running && snapshot.process_alive;
3438        // Registration is the difference between the two protocols and the only
3439        // one: a subc module that has not registered cannot serve a request even
3440        // though its process is up, and a `none` module never registers at all,
3441        // so requiring it there would pin `live` to false for the whole life of
3442        // a perfectly healthy process.
3443        let live = match protocol {
3444            ModuleProtocol::Subc => running_process && registration_active,
3445            ModuleProtocol::None => running_process,
3446        };
3447
3448        Ok((
3449            ModuleStatus {
3450                waiting_for_previous_pid: snapshot.waiting_for_previous_pid,
3451                module_id: self.inner.module_id.clone(),
3452                launch_nonce_source: observed_launch_nonce_source(&snapshot),
3453                state: snapshot.state,
3454                enabled: snapshot.enabled,
3455                process_alive: snapshot.process_alive,
3456                registration_active,
3457                protocol,
3458                live,
3459                restart_count,
3460                lifetime_restarts: snapshot.lifetime_restarts,
3461                spawn_generation: snapshot.spawn_generation,
3462                max_restarts: self.inner.restart_policy.max_restarts,
3463                restart_window: self.inner.restart_policy.window,
3464                drain_timeout,
3465                restart_backoff: self.inner.restart_policy.backoff,
3466                restart_max_backoff: self.inner.restart_policy.max_backoff,
3467                pid: snapshot.reported_pid(),
3468                spawned_at_ms: snapshot.spawned_at_ms,
3469                spawned_from: snapshot.spawned_from,
3470                process_start_time: snapshot.process_start_time,
3471                last_exit: snapshot.last_exit,
3472                health: snapshot.health,
3473            },
3474            snapshot.spawned_file_identity.clone(),
3475        ))
3476    }
3477
3478    #[cfg(test)]
3479    pub(crate) fn hold_snapshot_for_test(
3480        &self,
3481        acquired: std::sync::mpsc::Sender<()>,
3482        hold: Duration,
3483    ) -> std::thread::JoinHandle<()> {
3484        let snapshot = Arc::clone(&self.inner.snapshot);
3485        std::thread::spawn(move || {
3486            let _guard = snapshot.lock().expect("test snapshot lock is not poisoned");
3487            acquired
3488                .send(())
3489                .expect("test receiver waits for snapshot lock");
3490            std::thread::sleep(hold);
3491        })
3492    }
3493
3494    /// The status and the running-image check for `supervisor.provenance`,
3495    /// taken from one status read. The exec acknowledgement can land between
3496    /// two separate reads, and the reply would then pair "no pid yet" with an
3497    /// image observed after the module started, which describes no single
3498    /// moment.
3499    pub(crate) async fn status_and_running_image_agreement(
3500        &self,
3501    ) -> Result<(ModuleStatus, subc_control::RunningImageAgreement), SuperviseError> {
3502        let (status, identity) = self.status_with_snapshot_lock(&self.inner.snapshot, None)?;
3503        let image = self
3504            .inner
3505            .provenance_probe
3506            .observe(
3507                status.pid,
3508                status.spawned_from.as_deref(),
3509                identity,
3510                status.process_start_time,
3511            )
3512            .await;
3513        Ok((status, image))
3514    }
3515
3516    pub(crate) async fn running_image_agreement(&self) -> subc_control::RunningImageAgreement {
3517        let snapshot = match lock_snapshot(&self.inner.snapshot) {
3518            Ok(snapshot) => snapshot.clone(),
3519            Err(_) => {
3520                return subc_control::RunningImageAgreement::Unavailable {
3521                    reason: subc_control::RunningImageUnavailableReason::NotRunning,
3522                };
3523            }
3524        };
3525        self.inner
3526            .provenance_probe
3527            .observe(
3528                snapshot.reported_pid(),
3529                snapshot.spawned_from.as_deref(),
3530                snapshot.spawned_file_identity.clone(),
3531                snapshot.process_start_time,
3532            )
3533            .await
3534    }
3535
3536    /// Memory and CPU time of the module's current process, read now. Only the
3537    /// process the supervisor spawned is read, not processes it has started.
3538    pub(crate) fn child_resource_usage(&self) -> subc_control::ChildResourceUsage {
3539        #[cfg(windows)]
3540        {
3541            let image = lock_snapshot(&self.inner.snapshot)
3542                .ok()
3543                .and_then(|snapshot| {
3544                    snapshot
3545                        .spawned_file_identity
3546                        .as_ref()
3547                        .and_then(|identity| identity.windows_image.clone())
3548                });
3549            match image {
3550                Some(image) => {
3551                    crate::child_resources::read_windows_handle(image.process(), image.start_time())
3552                }
3553                None => subc_control::ChildResourceUsage::Unavailable {
3554                    reason: subc_control::ChildResourceUnavailableReason::NotRunning,
3555                },
3556            }
3557        }
3558        #[cfg(not(windows))]
3559        {
3560            let (pid, start_time) = match lock_snapshot(&self.inner.snapshot) {
3561                Ok(snapshot) => (snapshot.reported_pid(), snapshot.process_start_time),
3562                Err(_) => {
3563                    return subc_control::ChildResourceUsage::Unavailable {
3564                        reason: subc_control::ChildResourceUnavailableReason::Unreadable,
3565                    }
3566                }
3567            };
3568            crate::child_resources::read(pid, start_time)
3569        }
3570    }
3571
3572    pub(crate) fn will_recover_after_connection_loss(&self) -> Result<bool, SuperviseError> {
3573        let mut snapshot = lock_snapshot(&self.inner.snapshot)?;
3574        Ok(match snapshot.state {
3575            ModuleState::Restarting => true,
3576            ModuleState::Failed | ModuleState::Disabled => false,
3577            _ => daemon_will_restart(&mut snapshot, &self.inner.restart_policy, Instant::now()),
3578        })
3579    }
3580
3581    #[cfg(test)]
3582    pub(crate) fn is_warming(&self) -> Result<bool, SuperviseError> {
3583        self.is_warming_with_snapshot_lock(None)
3584    }
3585
3586    pub(crate) fn is_warming_for_control(
3587        &self,
3588        caller: &'static str,
3589    ) -> Result<bool, SuperviseError> {
3590        self.is_warming_with_snapshot_lock(Some(caller))
3591    }
3592
3593    fn is_warming_with_snapshot_lock(
3594        &self,
3595        caller: Option<&'static str>,
3596    ) -> Result<bool, SuperviseError> {
3597        let snapshot = match caller {
3598            Some(caller) => {
3599                lock_snapshot_for_control(&self.inner.snapshot, &self.inner.module_id, caller)?
3600            }
3601            None => lock_snapshot(&self.inner.snapshot)?,
3602        }
3603        .clone();
3604        Ok(matches!(
3605            snapshot.state,
3606            ModuleState::Starting | ModuleState::Running | ModuleState::Restarting
3607        ))
3608    }
3609
3610    /// Drain the module and stop monitoring it.
3611    pub async fn drain(&self) -> Result<(), SuperviseError> {
3612        self.stop().await
3613    }
3614
3615    pub(crate) async fn retire_requested(
3616        &self,
3617        requester: Option<Principal>,
3618    ) -> Result<(), SuperviseError> {
3619        match self.state()? {
3620            ModuleState::Stopped | ModuleState::Failed => return Ok(()),
3621            ModuleState::Starting
3622            | ModuleState::Running
3623            | ModuleState::Unresponsive
3624            | ModuleState::Restarting
3625            | ModuleState::Draining
3626            | ModuleState::Disabled => {}
3627        }
3628
3629        let (reply_tx, reply_rx) = oneshot::channel();
3630        self.inner
3631            .commands
3632            .send(SupervisorCommand::Retire {
3633                requester,
3634                reply: reply_tx,
3635            })
3636            .await
3637            .map_err(|_| SuperviseError::CommandClosed {
3638                module_id: self.inner.module_id.clone(),
3639            })?;
3640        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3641            module_id: self.inner.module_id.clone(),
3642        })?
3643    }
3644
3645    pub async fn stop(&self) -> Result<(), SuperviseError> {
3646        self.stop_requested(None).await
3647    }
3648
3649    pub(crate) async fn stop_requested(
3650        &self,
3651        requester: Option<Principal>,
3652    ) -> Result<(), SuperviseError> {
3653        match self.state()? {
3654            ModuleState::Stopped | ModuleState::Failed => return Ok(()),
3655            ModuleState::Starting
3656            | ModuleState::Running
3657            | ModuleState::Unresponsive
3658            | ModuleState::Restarting
3659            | ModuleState::Draining
3660            | ModuleState::Disabled => {}
3661        }
3662
3663        let (reply_tx, reply_rx) = oneshot::channel();
3664        self.inner
3665            .commands
3666            .send(SupervisorCommand::Drain {
3667                requester,
3668                reply: reply_tx,
3669            })
3670            .await
3671            .map_err(|_| SuperviseError::CommandClosed {
3672                module_id: self.inner.module_id.clone(),
3673            })?;
3674        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3675            module_id: self.inner.module_id.clone(),
3676        })?
3677    }
3678
3679    pub async fn restart(&self, drain_timeout_ms: Option<u64>) -> Result<(), SuperviseError> {
3680        self.restart_requested(drain_timeout_ms, None).await
3681    }
3682
3683    pub(crate) async fn restart_requested(
3684        &self,
3685        drain_timeout_ms: Option<u64>,
3686        requester: Option<Principal>,
3687    ) -> Result<(), SuperviseError> {
3688        let received_at_generation = lock_snapshot(&self.inner.snapshot)?.spawn_generation;
3689        let (reply_tx, reply_rx) = oneshot::channel();
3690        self.inner
3691            .commands
3692            .send(SupervisorCommand::Restart {
3693                requester,
3694                drain_timeout_ms,
3695                received_at_generation,
3696                queued_at: Instant::now(),
3697                reply: reply_tx,
3698            })
3699            .await
3700            .map_err(|_| SuperviseError::CommandClosed {
3701                module_id: self.inner.module_id.clone(),
3702            })?;
3703        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3704            module_id: self.inner.module_id.clone(),
3705        })?
3706    }
3707
3708    /// Blue/green restart: see [`SupervisorCommand::Swap`] and the
3709    /// `supervisor_swap` module. Returns once the swap has cut over (the old
3710    /// process then drains in the background of the supervise loop) or has
3711    /// failed, leaving the old process serving.
3712    pub async fn swap(&self, ready_timeout: Option<Duration>) -> Result<(), SuperviseError> {
3713        self.swap_requested(ready_timeout, None).await
3714    }
3715
3716    pub(crate) async fn swap_requested(
3717        &self,
3718        ready_timeout: Option<Duration>,
3719        requester: Option<Principal>,
3720    ) -> Result<(), SuperviseError> {
3721        let (reply_tx, reply_rx) = oneshot::channel();
3722        self.inner
3723            .commands
3724            .send(SupervisorCommand::Swap {
3725                requester,
3726                ready_timeout,
3727                reply: reply_tx,
3728            })
3729            .await
3730            .map_err(|_| SuperviseError::CommandClosed {
3731                module_id: self.inner.module_id.clone(),
3732            })?;
3733        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3734            module_id: self.inner.module_id.clone(),
3735        })?
3736    }
3737
3738    pub async fn reload(&self) -> Result<(), SuperviseError> {
3739        self.reload_requested(None).await
3740    }
3741
3742    pub(crate) async fn reload_requested(
3743        &self,
3744        requester: Option<Principal>,
3745    ) -> Result<(), SuperviseError> {
3746        let (reply_tx, reply_rx) = oneshot::channel();
3747        self.inner
3748            .commands
3749            .send(SupervisorCommand::Reload {
3750                requester,
3751                reply: reply_tx,
3752            })
3753            .await
3754            .map_err(|_| SuperviseError::CommandClosed {
3755                module_id: self.inner.module_id.clone(),
3756            })?;
3757        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3758            module_id: self.inner.module_id.clone(),
3759        })?
3760    }
3761
3762    pub async fn set_enabled(&self, enabled: bool) -> Result<bool, SuperviseError> {
3763        self.set_enabled_requested(enabled, None).await
3764    }
3765
3766    pub(crate) async fn set_enabled_requested(
3767        &self,
3768        enabled: bool,
3769        requester: Option<Principal>,
3770    ) -> Result<bool, SuperviseError> {
3771        let (reply_tx, reply_rx) = oneshot::channel();
3772        self.inner
3773            .commands
3774            .send(SupervisorCommand::SetEnabled {
3775                requester,
3776                enabled,
3777                reply: reply_tx,
3778            })
3779            .await
3780            .map_err(|_| SuperviseError::CommandClosed {
3781                module_id: self.inner.module_id.clone(),
3782            })?;
3783        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3784            module_id: self.inner.module_id.clone(),
3785        })?
3786    }
3787
3788    /// The current process's protocol, or the configured protocol when down.
3789    /// A rescan stores the next launch spec without changing how an existing
3790    /// process registers, serves routes, is probed, or exits.
3791    pub(crate) fn declared_protocol(&self) -> Result<ModuleProtocol, SuperviseError> {
3792        let configured = self
3793            .inner
3794            .configuration
3795            .lock()
3796            .map_err(|_| SuperviseError::StatePoisoned {
3797                module_id: Some(self.inner.module_id.clone()),
3798            })?
3799            .spec
3800            .protocol;
3801        let state = lock_snapshot(&self.inner.snapshot)?;
3802        Ok(state.spawned_protocol.unwrap_or(configured))
3803    }
3804
3805    pub(crate) fn configuration(&self) -> Result<(ModuleSpec, HealthConfig), SuperviseError> {
3806        let configuration =
3807            self.inner
3808                .configuration
3809                .lock()
3810                .map_err(|_| SuperviseError::StatePoisoned {
3811                    module_id: Some(self.inner.module_id.clone()),
3812                })?;
3813        Ok((configuration.spec.clone(), configuration.health.clone()))
3814    }
3815
3816    /// Replace this module's launch spec, keeping its health and drain policy,
3817    /// the way a rescan does for a changed config entry. The running process is
3818    /// untouched; the next spawn (a restart, or a swap's candidate) uses it.
3819    #[cfg(any(test, feature = "test-support"))]
3820    pub async fn update_spec_for_test(&self, spec: ModuleSpec) -> Result<(), SuperviseError> {
3821        let (_, health) = self.configuration()?;
3822        let drain_timeout_ms = u64::try_from(
3823            self.inner
3824                .effective_drain_timeout
3825                .lock()
3826                .unwrap_or_else(|poisoned| poisoned.into_inner())
3827                .as_millis(),
3828        )
3829        .ok();
3830        self.update_configuration(spec, health, drain_timeout_ms)
3831            .await
3832    }
3833
3834    pub(crate) async fn update_configuration(
3835        &self,
3836        spec: ModuleSpec,
3837        health: HealthConfig,
3838        drain_timeout_ms: Option<u64>,
3839    ) -> Result<(), SuperviseError> {
3840        if spec.module_id != self.inner.module_id {
3841            return Err(SuperviseError::InvalidSpec {
3842                reason: "a supervised module's module_id cannot be changed".to_string(),
3843            });
3844        }
3845        validate_spec(&spec)?;
3846        let (reply_tx, reply_rx) = oneshot::channel();
3847        self.inner
3848            .commands
3849            .send(SupervisorCommand::UpdateConfiguration {
3850                spec: spec.clone(),
3851                health: health.clone(),
3852                drain_timeout_ms,
3853                reply: reply_tx,
3854            })
3855            .await
3856            .map_err(|_| SuperviseError::CommandClosed {
3857                module_id: self.inner.module_id.clone(),
3858            })?;
3859        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3860            module_id: self.inner.module_id.clone(),
3861        })?;
3862        let mut configuration =
3863            self.inner
3864                .configuration
3865                .lock()
3866                .map_err(|_| SuperviseError::StatePoisoned {
3867                    module_id: Some(self.inner.module_id.clone()),
3868                })?;
3869        configuration.spec = spec;
3870        configuration.health = health;
3871        Ok(())
3872    }
3873}
3874
3875impl Drop for SupervisedModuleInner {
3876    fn drop(&mut self) {
3877        let Ok(mut monitor) = self.monitor.lock() else {
3878            return;
3879        };
3880        if let Some(monitor) = monitor.as_ref().filter(|monitor| !monitor.is_finished()) {
3881            let _ = update_snapshot(&self.snapshot, Some(&self.module_id), |state| {
3882                state.state = ModuleState::Stopped;
3883                clear_current_process_facts(state);
3884            });
3885            monitor.abort();
3886        }
3887        let _ = monitor.take();
3888    }
3889}
3890
3891#[derive(Debug)]
3892enum SupervisorCommand {
3893    Drain {
3894        requester: Option<Principal>,
3895        reply: oneshot::Sender<Result<(), SuperviseError>>,
3896    },
3897    Retire {
3898        requester: Option<Principal>,
3899        reply: oneshot::Sender<Result<(), SuperviseError>>,
3900    },
3901    Restart {
3902        requester: Option<Principal>,
3903        /// Operator override for this one restart's drain budget, in ms. `None`
3904        /// uses the module's configured/default budget; `Some(0)` cuts
3905        /// immediately (wedge bounce: a stuck request never settles, so
3906        /// waiting only delays recovery).
3907        drain_timeout_ms: Option<u64>,
3908        /// The module's `spawn_generation` when the request was received, before
3909        /// it waited in the command queue. A queued restart whose module has
3910        /// since spawned a newer process is already satisfied (see the handler).
3911        received_at_generation: u64,
3912        /// When the request entered the command queue, so the handler can log
3913        /// how long it waited behind the loop's other work.
3914        queued_at: Instant,
3915        reply: oneshot::Sender<Result<(), SuperviseError>>,
3916    },
3917    Reload {
3918        requester: Option<Principal>,
3919        reply: oneshot::Sender<Result<(), SuperviseError>>,
3920    },
3921    SetEnabled {
3922        requester: Option<Principal>,
3923        enabled: bool,
3924        reply: oneshot::Sender<Result<bool, SuperviseError>>,
3925    },
3926    UpdateConfiguration {
3927        spec: ModuleSpec,
3928        health: HealthConfig,
3929        /// Per-module drain override from the new config; `None` re-resolves to
3930        /// the supervisor-wide default.
3931        drain_timeout_ms: Option<u64>,
3932        reply: oneshot::Sender<()>,
3933    },
3934    Swap {
3935        requester: Option<Principal>,
3936        /// How long the candidate may take to register and declare itself
3937        /// ready. `None` uses [`DEFAULT_SWAP_READY_TIMEOUT`].
3938        ready_timeout: Option<Duration>,
3939        /// Answered at cutover or failure; the incumbent's drain follows.
3940        reply: oneshot::Sender<Result<(), SuperviseError>>,
3941    },
3942}
3943
3944#[derive(Debug)]
3945pub enum SuperviseError {
3946    InvalidSpec {
3947        reason: String,
3948    },
3949    Spawn {
3950        program: PathBuf,
3951        source: io::Error,
3952        cgroup_path: Option<PathBuf>,
3953    },
3954    Cgroup {
3955        module_id: String,
3956        source: io::Error,
3957    },
3958    /// CSPRNG failure generating a reserved module's launch nonce. Fail loud rather
3959    /// than spawn a reserved module without its identity binding.
3960    LaunchNonce {
3961        reason: String,
3962    },
3963    Wait {
3964        module_id: String,
3965        source: io::Error,
3966    },
3967    Kill {
3968        module_id: String,
3969        source: io::Error,
3970    },
3971    Forwarding(ForwardingError),
3972    Registry(RegistryError),
3973    ReloadUnavailable {
3974        module_id: String,
3975        reason: String,
3976    },
3977    /// An operator restart/reload was requested for a module that is currently
3978    /// disabled. Restart/reload cycle a *running* module; a disabled module must
3979    /// be explicitly re-enabled (set_enabled(true)) rather than silently started
3980    /// by a restart, so these commands are rejected instead of re-enabling it.
3981    Disabled {
3982        module_id: String,
3983    },
3984    ReloadFailed {
3985        module_id: String,
3986        reason: String,
3987    },
3988    RegistrationStillActive {
3989        module_id: String,
3990        waited: Duration,
3991    },
3992    StatePoisoned {
3993        module_id: Option<String>,
3994    },
3995    CommandClosed {
3996        module_id: String,
3997    },
3998    /// A restart or reload arrived while a swap's candidate was warming. The
3999    /// swap owns the module until it cuts over or fails; a stop or disable
4000    /// would have aborted it instead.
4001    SwapInProgress {
4002        module_id: String,
4003    },
4004    /// A swap was refused before anything was spawned.
4005    SwapRefused {
4006        module_id: String,
4007        reason: SwapRefusal,
4008    },
4009    /// A swap spawned a candidate and gave up on it. The candidate has been
4010    /// killed and its slot freed; the incumbent was left serving and was never
4011    /// drained, except in the one `CutoverLost` case described on that arm.
4012    SwapFailed {
4013        module_id: String,
4014        arm: SwapFailureArm,
4015        detail: String,
4016        /// How the candidate exited, when it exited on its own before the
4017        /// supervisor gave up on it.
4018        candidate_exit: Option<ExitReport>,
4019    },
4020}
4021
4022/// Why a swap was refused before a candidate was spawned.
4023#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4024pub enum SwapRefusal {
4025    /// The module's config does not declare `overlap: "safe"`.
4026    OverlapExclusive,
4027    /// The module is not registered, so there is no incumbent to keep serving
4028    /// and nothing a swap would improve on; a plain restart is the tool.
4029    NotRegistered,
4030    /// The module does not speak the subc wire, so a candidate could never
4031    /// register or declare itself ready.
4032    ProtocolNone,
4033    /// The supervisor lacks the forwarding table (to cut routes over) or the
4034    /// shared handle (to admit the candidate's HELLO) that a swap needs.
4035    NotConfigured,
4036    /// A swap is already open for this module.
4037    AlreadySwapping,
4038}
4039
4040impl SwapRefusal {
4041    pub fn as_str(self) -> &'static str {
4042        match self {
4043            Self::OverlapExclusive => "overlap_exclusive",
4044            Self::NotRegistered => "not_registered",
4045            Self::ProtocolNone => "protocol_none",
4046            Self::NotConfigured => "not_configured",
4047            Self::AlreadySwapping => "already_swapping",
4048        }
4049    }
4050}
4051
4052/// Which failure arm ended a swap. Every arm but one leaves the incumbent
4053/// serving and undrained; see `CutoverLost`.
4054#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4055pub enum SwapFailureArm {
4056    /// The candidate process could not be started.
4057    SpawnFailed,
4058    /// The candidate did not register within the readiness budget.
4059    NeverRegistered,
4060    /// The candidate registered but did not declare itself ready in time.
4061    NeverReady,
4062    /// The candidate exited before cutover.
4063    CandidateExited,
4064    /// The candidate declared itself ready but failed its health probe.
4065    CandidateUnhealthy,
4066    /// An operator stop, disable or retire arrived while the candidate warmed.
4067    /// The candidate was killed and the operator's command then carried out on
4068    /// the incumbent.
4069    Interrupted,
4070    /// The candidate's connection closed at the moment of cutover. If it
4071    /// closed before forwarding moved, the incumbent is untouched. If it closed
4072    /// between the forwarding and registry halves of cutover, forwarding can no
4073    /// longer route to the incumbent, so the module is restarted plainly.
4074    CutoverLost,
4075}
4076
4077impl SwapFailureArm {
4078    pub fn as_str(self) -> &'static str {
4079        match self {
4080            Self::SpawnFailed => "spawn_failed",
4081            Self::NeverRegistered => "never_registered",
4082            Self::NeverReady => "never_ready",
4083            Self::CandidateExited => "candidate_exited",
4084            Self::CandidateUnhealthy => "candidate_unhealthy",
4085            Self::Interrupted => "interrupted",
4086            Self::CutoverLost => "cutover_lost",
4087        }
4088    }
4089}
4090
4091impl fmt::Display for SuperviseError {
4092    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4093        match self {
4094            Self::InvalidSpec { reason } => write!(f, "invalid module spec: {reason}"),
4095            Self::Spawn {
4096                program,
4097                source,
4098                cgroup_path: Some(cgroup_path),
4099            } => write!(
4100                f,
4101                "failed to place module in cgroup '{}' while spawning '{}': {source}",
4102                cgroup_path.display(),
4103                program.display()
4104            ),
4105            Self::Spawn {
4106                program,
4107                source,
4108                cgroup_path: None,
4109            } => write!(
4110                f,
4111                "failed to spawn module '{}': {source}",
4112                program.display()
4113            ),
4114            Self::Cgroup { module_id, source } => {
4115                write!(
4116                    f,
4117                    "failed to prepare cgroup for module '{module_id}': {source}"
4118                )
4119            }
4120            Self::LaunchNonce { reason } => {
4121                write!(
4122                    f,
4123                    "failed to generate reserved-module launch nonce: {reason}"
4124                )
4125            }
4126            Self::Wait { module_id, source } => {
4127                write!(f, "failed to wait for module '{module_id}': {source}")
4128            }
4129            Self::Kill { module_id, source } => {
4130                write!(f, "failed to kill module '{module_id}': {source}")
4131            }
4132            Self::Forwarding(err) => write!(f, "forwarding error: {err}"),
4133            Self::Registry(err) => write!(f, "registry error: {err}"),
4134            Self::ReloadUnavailable { module_id, reason } => {
4135                write!(f, "reload unavailable for module '{module_id}': {reason}")
4136            }
4137            Self::Disabled { module_id } => {
4138                write!(
4139                    f,
4140                    "module '{module_id}' is disabled; enable it before restart or reload"
4141                )
4142            }
4143            Self::ReloadFailed { module_id, reason } => {
4144                write!(f, "reload failed for module '{module_id}': {reason}")
4145            }
4146            Self::RegistrationStillActive { module_id, waited } => write!(
4147                f,
4148                "module '{module_id}' registration remained active after waiting {waited:?}"
4149            ),
4150            Self::StatePoisoned { module_id } => match module_id {
4151                Some(module_id) => {
4152                    write!(f, "supervisor state for module '{module_id}' was poisoned")
4153                }
4154                None => write!(f, "supervisor state was poisoned"),
4155            },
4156            Self::CommandClosed { module_id } => {
4157                write!(
4158                    f,
4159                    "supervisor command channel for module '{module_id}' is closed"
4160                )
4161            }
4162            Self::SwapInProgress { module_id } => write!(
4163                f,
4164                "module '{module_id}' is being swapped; retry once the swap has cut over or failed, or stop the module to abort the swap"
4165            ),
4166            Self::SwapRefused { module_id, reason } => match reason {
4167                SwapRefusal::OverlapExclusive => write!(
4168                    f,
4169                    "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"
4170                ),
4171                SwapRefusal::NotRegistered => write!(
4172                    f,
4173                    "module '{module_id}' is not registered, so there is no serving process to keep while a replacement warms; use a plain restart"
4174                ),
4175                SwapRefusal::ProtocolNone => write!(
4176                    f,
4177                    "module '{module_id}' is protocol: \"none\" and never registers, so a swap could never see its replacement become ready; use a plain restart"
4178                ),
4179                SwapRefusal::NotConfigured => write!(
4180                    f,
4181                    "module '{module_id}' cannot be swapped: the supervisor was built without the forwarding table or shared handle a swap needs"
4182                ),
4183                SwapRefusal::AlreadySwapping => {
4184                    write!(f, "module '{module_id}' is already being swapped")
4185                }
4186            },
4187            Self::SwapFailed {
4188                module_id,
4189                arm,
4190                detail,
4191                ..
4192            } => write!(
4193                f,
4194                "swap of module '{module_id}' failed ({}): {detail}; the running process was left serving",
4195                arm.as_str()
4196            ),
4197        }
4198    }
4199}
4200
4201impl Error for SuperviseError {
4202    fn source(&self) -> Option<&(dyn Error + 'static)> {
4203        match self {
4204            Self::Spawn { source, .. }
4205            | Self::Cgroup { source, .. }
4206            | Self::Wait { source, .. }
4207            | Self::Kill { source, .. } => Some(source),
4208            Self::Forwarding(err) => Some(err),
4209            Self::Registry(err) => Some(err),
4210            Self::LaunchNonce { .. }
4211            | Self::InvalidSpec { .. }
4212            | Self::ReloadUnavailable { .. }
4213            | Self::Disabled { .. }
4214            | Self::ReloadFailed { .. }
4215            | Self::RegistrationStillActive { .. }
4216            | Self::StatePoisoned { .. }
4217            | Self::CommandClosed { .. }
4218            | Self::SwapInProgress { .. }
4219            | Self::SwapRefused { .. }
4220            | Self::SwapFailed { .. } => None,
4221        }
4222    }
4223}
4224
4225pub(crate) fn validate_spec(spec: &ModuleSpec) -> Result<(), SuperviseError> {
4226    if spec.module_id.trim().is_empty() {
4227        return Err(SuperviseError::InvalidSpec {
4228            reason: "module_id must not be empty".to_string(),
4229        });
4230    }
4231
4232    Ok(())
4233}
4234
4235#[derive(Debug, Default)]
4236struct HealthProbeRuntime {
4237    configured_health: Option<HealthConfig>,
4238    registered_connection: Option<crate::ConnectionId>,
4239    advertised: bool,
4240    next_probe_at: Option<Instant>,
4241    probe_index: u64,
4242}
4243
4244fn running_protocol(spec: &ModuleSpec, snapshot: &SharedSnapshot) -> ModuleProtocol {
4245    lock_snapshot(snapshot)
4246        .ok()
4247        .and_then(|state| state.spawned_protocol)
4248        .unwrap_or(spec.protocol)
4249}
4250
4251impl HealthProbeRuntime {
4252    fn refresh_registration(
4253        &mut self,
4254        spec: &ModuleSpec,
4255        runtime: &SupervisorRuntimeConfig,
4256        registry: &Registry,
4257        snapshot: &SharedSnapshot,
4258    ) {
4259        if self.configured_health.as_ref() != Some(&runtime.health) {
4260            self.configured_health = Some(runtime.health.clone());
4261            self.next_probe_at = None;
4262            self.registered_connection = None;
4263            self.probe_index = 0;
4264        }
4265        // A non-wire process never registers. Only an explicitly configured
4266        // HTTP endpoint can arm its health probe; an absent HELLO is not a
4267        // health failure for that kind of process.
4268        if running_protocol(spec, snapshot) == ModuleProtocol::None {
4269            self.registered_connection = None;
4270            self.advertised = runtime.health.http.is_some();
4271            if !self.advertised {
4272                self.next_probe_at = None;
4273                let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4274                    let unknown = ModuleHealthStatus::default();
4275                    if state.health != unknown {
4276                        state.health = unknown;
4277                    }
4278                });
4279            } else if self.next_probe_at.is_none() {
4280                self.next_probe_at = Some(
4281                    Instant::now()
4282                        + jittered_health_delay(&spec.module_id, 0, runtime.health.cadence),
4283                );
4284            }
4285            return;
4286        }
4287
4288        let registration = match registry.get_module(&spec.module_id) {
4289            Ok(registration) => registration,
4290            Err(err) => {
4291                warn!(module_id = %spec.module_id, error = %err, "health prober could not read registry");
4292                self.advertised = false;
4293                self.next_probe_at = None;
4294                return;
4295            }
4296        };
4297
4298        let Some(registration) = registration else {
4299            self.registered_connection = None;
4300            self.advertised = false;
4301            self.next_probe_at = None;
4302            return;
4303        };
4304
4305        let advertised = registration
4306            .control_ops
4307            .iter()
4308            .any(|op| op == MODULE_CONTROL_OP_HEALTH_CHECK);
4309        if !advertised {
4310            self.registered_connection = Some(registration.connection_id);
4311            self.advertised = false;
4312            self.next_probe_at = None;
4313            let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4314                if state.health.status != SupervisorHealthStatus::Unknown
4315                    || state.health.consecutive_failures != 0
4316                    || state.health.last_probe_ms.is_some()
4317                    || state.health.detail.is_some()
4318                    || state.health.metrics.is_some()
4319                {
4320                    state.health.status = SupervisorHealthStatus::Unknown;
4321                    state.health.consecutive_failures = 0;
4322                    state.health.last_probe_ms = None;
4323                    state.health.detail = None;
4324                    state.health.metrics = None;
4325                }
4326            });
4327            return;
4328        }
4329
4330        let reregistered = self.registered_connection != Some(registration.connection_id);
4331        self.registered_connection = Some(registration.connection_id);
4332        self.advertised = true;
4333        if reregistered || self.next_probe_at.is_none() {
4334            self.probe_index = 0;
4335            self.next_probe_at = Some(
4336                Instant::now() + jittered_health_delay(&spec.module_id, 0, runtime.health.cadence),
4337            );
4338            let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4339                state.health.status = SupervisorHealthStatus::Unknown;
4340                state.health.consecutive_failures = 0;
4341                state.health.detail = None;
4342                state.health.metrics = None;
4343            });
4344        }
4345    }
4346
4347    fn wake_after(&self) -> Option<Duration> {
4348        if !self.advertised {
4349            return None;
4350        }
4351        self.next_probe_at
4352            .map(|next| next.saturating_duration_since(Instant::now()))
4353    }
4354
4355    fn due(&self) -> bool {
4356        self.advertised
4357            && self
4358                .next_probe_at
4359                .is_some_and(|next| Instant::now() >= next)
4360    }
4361
4362    fn schedule_next(&mut self, spec: &ModuleSpec, cadence: Duration) {
4363        self.probe_index = self.probe_index.wrapping_add(1);
4364        self.next_probe_at = Some(
4365            Instant::now() + jittered_health_delay(&spec.module_id, self.probe_index, cadence),
4366        );
4367    }
4368}
4369
4370/// What a failed health probe actually OBSERVED, kept apart from how it reads.
4371///
4372/// This was a struct with a single `message: String`, and every one of the
4373/// fifteen construction sites collapsed into it. Each site knows exactly what it
4374/// saw -- the lane is gone, the module did not answer in time, the module
4375/// answered with the wrong thing -- and `handle_health_probe_failure` then
4376/// treated all of them identically: increment a counter, compare to a threshold,
4377/// restart the module. THE DISTINCTION EXISTED AT EVERY CALL SITE AND WAS
4378/// DESTROYED BEFORE THE DECISION THAT NEEDED IT.
4379///
4380/// The distinction that matters is not severity, it is EVIDENTIAL WEIGHT:
4381///
4382/// * `LaneDead` is PROOF. The module's control connection is gone; nothing will
4383///   answer on it again.
4384/// * `NoAnswer` is ABSENCE OF EVIDENCE. It is consistent with a wedged module
4385///   AND with a perfectly healthy one that lost a CPU race -- which is what
4386///   happens under machine load, and is how this supervisor killed a healthy
4387///   module three times in one day.
4388/// * `BadAnswer` proves the module is ALIVE. It replied; the reply was wrong.
4389///   Restarting on it is defensible, but it is not the silence case and should
4390///   never be counted as one.
4391/// * `Misconfigured` is a daemon-side fault. The module has not been asked
4392///   anything, so it cannot be evidence about the module at all.
4393///
4394/// The asymmetry is the whole point: under saturation the WEAKEST signal is the
4395/// one that fires most often, and while every variant collapsed into one string
4396/// it carried the same weight as the strongest.
4397///
4398/// LIVE BEHAVIOUR TODAY, stated here because this doc block describes the
4399/// DESIGN and a reader stopping at it gets the build backwards: the restart
4400/// decision does NOT yet consult this classification -- consecutive `NoAnswer`
4401/// probes still increment the failure streak and drive escalation at the
4402/// threshold (see `is_proof_of_death` below for why that is deliberate and
4403/// what gates the change). Absence of evidence restarts modules today.
4404#[derive(Debug)]
4405enum HealthProbeEvidence {
4406    /// The module's control lane is gone. Proof of death.
4407    LaneDead,
4408    /// No reply within the deadline. Proves nothing about the module's state.
4409    NoAnswer,
4410    /// The module replied, but not with a usable health report. Proves it is alive.
4411    BadAnswer,
4412    /// The daemon could not ask. Says nothing about the module.
4413    Misconfigured,
4414}
4415
4416#[derive(Debug)]
4417struct HealthProbeError {
4418    evidence: HealthProbeEvidence,
4419    message: String,
4420}
4421
4422impl HealthProbeError {
4423    fn lane_dead(message: impl Into<String>) -> Self {
4424        Self::with(HealthProbeEvidence::LaneDead, message)
4425    }
4426
4427    fn no_answer(message: impl Into<String>) -> Self {
4428        Self::with(HealthProbeEvidence::NoAnswer, message)
4429    }
4430
4431    fn bad_answer(message: impl Into<String>) -> Self {
4432        Self::with(HealthProbeEvidence::BadAnswer, message)
4433    }
4434
4435    fn misconfigured(message: impl Into<String>) -> Self {
4436        Self::with(HealthProbeEvidence::Misconfigured, message)
4437    }
4438
4439    fn with(evidence: HealthProbeEvidence, message: impl Into<String>) -> Self {
4440        Self {
4441            evidence,
4442            message: message.into(),
4443        }
4444    }
4445
4446    /// Whether this observation is proof the module cannot serve.
4447    ///
4448    /// Only `LaneDead` qualifies. `NoAnswer` is deliberately excluded: it is the
4449    /// variant that fires under CPU starvation, and treating it as proof is the
4450    /// defect this enum exists to make impossible to reintroduce silently.
4451    ///
4452    /// NOT YET CONSULTED BY THE RESTART DECISION, deliberately. Requiring proof
4453    /// to restart also needs a bound for the case it excludes -- a genuinely
4454    /// wedged module, alive but never answering -- and that bound must come from
4455    /// the distribution of real late-answer latencies, which nothing measures
4456    /// yet. Landing the classification first makes the later change a one-line
4457    /// decision against evidence that already exists, rather than two unproven
4458    /// changes at once.
4459    #[allow(dead_code)]
4460    fn is_proof_of_death(&self) -> bool {
4461        matches!(self.evidence, HealthProbeEvidence::LaneDead)
4462    }
4463
4464    /// Short stable label for logs and the health snapshot.
4465    ///
4466    /// An operator reading `ck health` currently cannot tell "the module is gone"
4467    /// from "the module did not answer in five seconds", because both render as
4468    /// prose in the same field. These labels are what make the two
4469    /// distinguishable at a glance, and they are what a later restart-policy
4470    /// change will be argued from.
4471    fn label(&self) -> &'static str {
4472        match self.evidence {
4473            HealthProbeEvidence::LaneDead => "lane-dead",
4474            HealthProbeEvidence::NoAnswer => "no-answer",
4475            HealthProbeEvidence::BadAnswer => "bad-answer",
4476            HealthProbeEvidence::Misconfigured => "daemon-misconfigured",
4477        }
4478    }
4479}
4480
4481impl fmt::Display for HealthProbeError {
4482    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4483        f.write_str(&self.message)
4484    }
4485}
4486
4487async fn run_health_probe_cycle(
4488    spec: &ModuleSpec,
4489    runtime: &SupervisorRuntimeConfig,
4490    registry: &Registry,
4491    process_liveness: &SupervisorProcessLiveness,
4492    snapshot: &SharedSnapshot,
4493    child: &mut Option<SupervisedChild>,
4494) {
4495    let now_ms = unix_ms_now();
4496    let http = (running_protocol(spec, snapshot) == ModuleProtocol::None)
4497        .then_some(runtime.health.http.as_deref())
4498        .flatten();
4499    let result = match http {
4500        Some(url) => probe_http_health(url, runtime.health.deadline).await,
4501        None => probe_module_health(&spec.module_id, runtime, None).await,
4502    };
4503    match result {
4504        Ok(report) => {
4505            handle_health_report(
4506                spec,
4507                runtime,
4508                registry,
4509                process_liveness,
4510                snapshot,
4511                child,
4512                report,
4513                now_ms,
4514            )
4515            .await;
4516        }
4517        Err(err) => {
4518            if http.is_some() {
4519                let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4520                    state.health.status = SupervisorHealthStatus::Failing;
4521                });
4522            }
4523            handle_health_probe_failure(
4524                spec,
4525                runtime,
4526                registry,
4527                process_liveness,
4528                snapshot,
4529                child,
4530                err,
4531                now_ms,
4532            )
4533            .await;
4534        }
4535    }
4536}
4537
4538pub(crate) struct HttpProbeTarget<'a> {
4539    address: std::net::SocketAddr,
4540    localhost: bool,
4541    authority: &'a str,
4542    path: String,
4543}
4544
4545/// Resolve only literal loopback endpoints, without DNS, redirects, proxies,
4546/// or TLS. A URL cannot turn a local health check into an outbound connection.
4547pub(crate) fn parse_http_probe_url(url: &str) -> Result<HttpProbeTarget<'_>, String> {
4548    if url.bytes().any(|b| b <= b' ' || b == 127) || url.contains('#') {
4549        return Err("must not contain whitespace, controls, or a fragment".into());
4550    }
4551    let rest = url
4552        .strip_prefix("http://")
4553        .ok_or("must use plain http://")?;
4554    let split = rest.find(['/', '?']).unwrap_or(rest.len());
4555    let (authority, suffix) = rest.split_at(split);
4556    let (host, port) = if let Some(rest) = authority.strip_prefix("[::1]") {
4557        ("::1", rest)
4558    } else {
4559        let split = authority.find(':').unwrap_or(authority.len());
4560        authority.split_at(split)
4561    };
4562    let ip: std::net::IpAddr = match host {
4563        "127.0.0.1" | "localhost" => std::net::Ipv4Addr::LOCALHOST.into(),
4564        "::1" => std::net::Ipv6Addr::LOCALHOST.into(),
4565        _ => return Err("host must be 127.0.0.1, [::1], or localhost".into()),
4566    };
4567    let port = if port.is_empty() {
4568        80
4569    } else {
4570        port.strip_prefix(':')
4571            .and_then(|p| p.parse::<u16>().ok())
4572            .filter(|p| *p > 0)
4573            .ok_or("must have a valid nonzero TCP port")?
4574    };
4575    let path = if suffix.is_empty() {
4576        "/".into()
4577    } else if suffix.starts_with('?') {
4578        format!("/{suffix}")
4579    } else {
4580        suffix.into()
4581    };
4582    Ok(HttpProbeTarget {
4583        address: std::net::SocketAddr::new(ip, port),
4584        localhost: host == "localhost",
4585        authority,
4586        path,
4587    })
4588}
4589
4590async fn probe_http_health(
4591    url: &str,
4592    deadline: Duration,
4593) -> Result<HealthReport, HealthProbeError> {
4594    use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader};
4595    let target = parse_http_probe_url(url).map_err(HealthProbeError::misconfigured)?;
4596    // Keep partial diagnostics outside the timed future so cancellation does
4597    // not discard a status line or body bytes already received.
4598    let mut response_status = String::new();
4599    let mut body = Vec::new();
4600    let probe = async {
4601        // Resolve localhost ourselves so a hosts-file override cannot turn
4602        // this into an outbound request, while IPv6-only local servers work.
4603        let connection = match tokio::net::TcpStream::connect(target.address).await {
4604            Err(_) if target.localhost => {
4605                tokio::net::TcpStream::connect((
4606                    std::net::Ipv6Addr::LOCALHOST,
4607                    target.address.port(),
4608                ))
4609                .await
4610            }
4611            result => result,
4612        };
4613        let mut stream = connection.map_err(|error| {
4614            HealthProbeError::no_answer(format!("HTTP connect failed: {error}"))
4615        })?;
4616        stream.write_all(format!("GET {} HTTP/1.1\r\nHost: {}\r\nAccept-Encoding: identity\r\nConnection: close\r\n\r\n", target.path, target.authority).as_bytes()).await
4617            .map_err(|error| HealthProbeError::no_answer(format!("HTTP write failed: {error}")))?;
4618        let mut reader = BufReader::new(stream);
4619        let mut budget = 16 * 1024;
4620        let status = http_line(&mut reader, &mut budget).await?;
4621        let mut words = status.split_ascii_whitespace();
4622        let version = words.next();
4623        let code = words
4624            .next()
4625            .filter(|word| word.len() == 3)
4626            .and_then(|word| word.parse::<u16>().ok());
4627        if !matches!(version, Some("HTTP/1.1" | "HTTP/1.0"))
4628            || !code.is_some_and(|code| (100..600).contains(&code))
4629        {
4630            return Err(HealthProbeError::bad_answer(format!(
4631                "invalid HTTP status: {status}"
4632            )));
4633        }
4634        let code = code.expect("validated status code");
4635        response_status = status.clone();
4636        let mut length = None;
4637        let mut chunked = false;
4638        loop {
4639            let line = http_line(&mut reader, &mut budget).await?;
4640            if line.is_empty() {
4641                break;
4642            }
4643            if let Some((name, value)) = line.split_once(':') {
4644                if name.eq_ignore_ascii_case("content-length") {
4645                    length = Some(value.trim().parse::<u64>().map_err(|_| {
4646                        HealthProbeError::bad_answer("invalid HTTP Content-Length")
4647                    })?);
4648                } else if name.eq_ignore_ascii_case("transfer-encoding") {
4649                    chunked = value.trim().eq_ignore_ascii_case("chunked");
4650                }
4651            }
4652        }
4653        if chunked {
4654            while body.len() < 200 {
4655                let line = http_line(&mut reader, &mut budget).await?;
4656                let size = u64::from_str_radix(line.split(';').next().unwrap_or("").trim(), 16)
4657                    .map_err(|_| HealthProbeError::bad_answer("invalid HTTP chunk size"))?;
4658                if size == 0 {
4659                    break;
4660                }
4661                let count = size.min((200 - body.len()) as u64) as usize;
4662                let start = body.len();
4663                (&mut reader)
4664                    .take(count as u64)
4665                    .read_to_end(&mut body)
4666                    .await
4667                    .map_err(|error| {
4668                        HealthProbeError::no_answer(format!("HTTP body read failed: {error}"))
4669                    })?;
4670                if body.len() - start != count {
4671                    return Err(HealthProbeError::bad_answer("truncated HTTP chunk"));
4672                }
4673                if size > count as u64 || body.len() == 200 {
4674                    break;
4675                }
4676                if !http_line(&mut reader, &mut budget).await?.is_empty() {
4677                    return Err(HealthProbeError::bad_answer("invalid HTTP chunk delimiter"));
4678                }
4679            }
4680        } else {
4681            reader
4682                .take(length.unwrap_or(200).min(200))
4683                .read_to_end(&mut body)
4684                .await
4685                .map_err(|error| {
4686                    HealthProbeError::no_answer(format!("HTTP body read failed: {error}"))
4687                })?;
4688        }
4689        if (200..300).contains(&code) {
4690            Ok(HealthReport::ok())
4691        } else {
4692            Err(HealthProbeError::bad_answer(
4693                "HTTP health endpoint returned non-2xx",
4694            ))
4695        }
4696    };
4697    let mut result = timeout(deadline, probe).await.unwrap_or_else(|_| {
4698        Err(HealthProbeError::no_answer(format!(
4699            "HTTP probe timed out after {deadline:?}"
4700        )))
4701    });
4702    if let Err(error) = &mut result {
4703        if !response_status.is_empty() {
4704            error.message = format!(
4705                "{}; {response_status}: {}",
4706                error.message,
4707                String::from_utf8_lossy(&body)
4708            );
4709        }
4710    }
4711    result
4712}
4713
4714async fn http_line(
4715    reader: &mut tokio::io::BufReader<tokio::net::TcpStream>,
4716    remaining: &mut usize,
4717) -> Result<String, HealthProbeError> {
4718    use tokio::io::{AsyncBufReadExt, AsyncReadExt};
4719    let mut line = Vec::new();
4720    (&mut *reader)
4721        .take(*remaining as u64)
4722        .read_until(b'\n', &mut line)
4723        .await
4724        .map_err(|error| {
4725            HealthProbeError::no_answer(format!("HTTP header read failed: {error}"))
4726        })?;
4727    *remaining -= line.len();
4728    if !line.ends_with(b"\r\n") {
4729        return Err(HealthProbeError::bad_answer(
4730            "HTTP headers are incomplete or exceed 16 KiB",
4731        ));
4732    }
4733    line.truncate(line.len() - 2);
4734    String::from_utf8(line).map_err(|_| HealthProbeError::bad_answer("HTTP header is not UTF-8"))
4735}
4736
4737async fn probe_module_health(
4738    module_id: &str,
4739    runtime: &SupervisorRuntimeConfig,
4740    drain_deadline: Option<Instant>,
4741) -> Result<HealthReport, HealthProbeError> {
4742    let Some(forwarding) = runtime.forwarding.as_ref() else {
4743        return Err(HealthProbeError::misconfigured(
4744            "supervisor was not configured with a forwarding table",
4745        ));
4746    };
4747    let probe_started_at = Instant::now();
4748    let mut deadline = probe_started_at + runtime.health.deadline;
4749    if let Some(drain_deadline) = drain_deadline {
4750        deadline = deadline.min(drain_deadline);
4751    }
4752    let pending = if drain_deadline.is_some() {
4753        forwarding.begin_drain_health_probe_rpc_for(
4754            module_id,
4755            MODULE_CONTROL_OP_HEALTH_CHECK,
4756            probe_started_at,
4757            deadline,
4758        )
4759    } else {
4760        forwarding.begin_health_probe_rpc_for(
4761            module_id,
4762            MODULE_CONTROL_OP_HEALTH_CHECK,
4763            probe_started_at,
4764            deadline,
4765        )
4766    }
4767    .map_err(|err| {
4768        // The endpoint is not registered, so there is no live control lane to
4769        // ask. That is the module being absent, not slow.
4770        HealthProbeError::lane_dead(format!("failed to begin health.check RPC: {err}"))
4771    })?;
4772    await_health_probe(forwarding, pending, deadline, runtime.health.deadline).await
4773}
4774
4775/// [`probe_module_health`] for one endpoint rather than the id's active one.
4776///
4777/// A swap probes two processes that no by-id lookup reaches: its candidate
4778/// before cutover, and its superseded incumbent (for busy gauges) while the
4779/// incumbent drains. `deadline_cap` bounds the probe the way a drain deadline
4780/// bounds the by-id drain probe.
4781async fn probe_endpoint_health(
4782    endpoint: crate::ModuleEndpointId,
4783    runtime: &SupervisorRuntimeConfig,
4784    deadline_cap: Option<Instant>,
4785) -> Result<HealthReport, HealthProbeError> {
4786    let Some(forwarding) = runtime.forwarding.as_ref() else {
4787        return Err(HealthProbeError::misconfigured(
4788            "supervisor was not configured with a forwarding table",
4789        ));
4790    };
4791    let probe_started_at = Instant::now();
4792    let mut deadline = probe_started_at + runtime.health.deadline;
4793    if let Some(cap) = deadline_cap {
4794        deadline = deadline.min(cap);
4795    }
4796    let pending = forwarding
4797        .begin_endpoint_health_probe_rpc_for(
4798            endpoint,
4799            MODULE_CONTROL_OP_HEALTH_CHECK,
4800            probe_started_at,
4801            deadline,
4802        )
4803        .map_err(|err| {
4804            HealthProbeError::lane_dead(format!("failed to begin health.check RPC: {err}"))
4805        })?;
4806    await_health_probe(forwarding, pending, deadline, runtime.health.deadline).await
4807}
4808
4809/// Send a begun health probe and classify its answer.
4810async fn await_health_probe(
4811    forwarding: &ForwardingTable,
4812    pending: PendingModuleControlRpc,
4813    deadline: Instant,
4814    probe_budget: Duration,
4815) -> Result<HealthReport, HealthProbeError> {
4816    let PendingModuleControlRpc {
4817        endpoint,
4818        module_sink,
4819        negotiated_ver,
4820        corr,
4821        receiver,
4822    } = pending;
4823    let body = serde_json::to_vec(&ModuleControlRequest::HealthCheck {}).map_err(|err| {
4824        HealthProbeError::misconfigured(format!("failed to encode health.check: {err}"))
4825    })?;
4826    let frame = Frame::build_with_version(
4827        negotiated_ver,
4828        FrameType::Request,
4829        control_flags(),
4830        0,
4831        0,
4832        corr,
4833        body,
4834    )
4835    .map_err(|err| {
4836        HealthProbeError::misconfigured(format!("failed to build health.check frame: {err}"))
4837    })?;
4838
4839    // The enqueue itself must be bounded by the probe deadline: FrameSink.send
4840    // blocks waiting for capacity when the module's egress queue is full, and an
4841    // unbounded await here freezes the whole supervision actor (it stops polling
4842    // Child::wait and supervisor commands), making the module unrecoverable
4843    // in-band. On timeout the probe fails like any transport failure.
4844    match timeout_at(deadline, module_sink.send(frame)).await {
4845        Ok(Ok(())) => {}
4846        Ok(Err(err)) => {
4847            let _ = forwarding.cancel_module_control_rpc(endpoint, corr);
4848            // A closed sink means the module's egress channel is gone -- the
4849            // receiving half is dropped when its connection tears down. Proof.
4850            return Err(HealthProbeError::lane_dead(format!(
4851                "failed to send health.check: {err}"
4852            )));
4853        }
4854        Err(_elapsed) => {
4855            let _ = forwarding.cancel_module_control_rpc(endpoint, corr);
4856            // A full egress queue means the module is not draining its socket, which
4857            // is consistent with a wedged module AND with one whose reader is merely
4858            // starved. Silence, not proof.
4859            return Err(HealthProbeError::no_answer(
4860                "health.check send timed out before enqueue (module egress full)",
4861            ));
4862        }
4863    }
4864
4865    match timeout_at(deadline, receiver).await {
4866        // Each arm records WHAT WAS OBSERVED. Four of them are the module
4867        // demonstrably answering -- rejected, non-health, malformed, wrong op --
4868        // and those prove it is alive even though the probe failed.
4869        Ok(Ok(ModuleControlRpcOutcome::Response(response))) => {
4870            response.health_report().ok_or_else(|| {
4871                HealthProbeError::bad_answer("health.check RPC returned a non-health response")
4872            })
4873        }
4874        Ok(Ok(ModuleControlRpcOutcome::Rejected(body))) => Err(HealthProbeError::bad_answer(
4875            format!("health.check rejected: {}", body.message),
4876        )),
4877        Ok(Ok(ModuleControlRpcOutcome::ModuleGone(message))) => {
4878            Err(HealthProbeError::lane_dead(message))
4879        }
4880        Ok(Ok(ModuleControlRpcOutcome::MalformedResponse(message))) => {
4881            Err(HealthProbeError::bad_answer(message))
4882        }
4883        Ok(Ok(ModuleControlRpcOutcome::UnexpectedOp { expected, actual })) => {
4884            Err(HealthProbeError::bad_answer(format!(
4885                "expected module-control op '{expected}', got '{actual}'"
4886            )))
4887        }
4888        // A reply that crosses the deadline before this waiter observes it is
4889        // still proof of life. The forwarding path records its end-to-end latency
4890        // before delivering this classification.
4891        Ok(Ok(ModuleControlRpcOutcome::DeadlineElapsed)) => Err(HealthProbeError::bad_answer(
4892            "module answered health.check after its daemon deadline",
4893        )),
4894        Ok(Err(_)) => Err(HealthProbeError::misconfigured(
4895            "health.check waiter was canceled before the module responded",
4896        )),
4897        Err(_) => {
4898            let _ = forwarding.tombstone_health_probe_rpc(endpoint, corr);
4899            Err(HealthProbeError::no_answer(format!(
4900                "module did not answer health.check within {probe_budget:?}"
4901            )))
4902        }
4903    }
4904}
4905
4906#[allow(clippy::too_many_arguments)]
4907async fn handle_health_report(
4908    spec: &ModuleSpec,
4909    runtime: &SupervisorRuntimeConfig,
4910    registry: &Registry,
4911    process_liveness: &SupervisorProcessLiveness,
4912    snapshot: &SharedSnapshot,
4913    child: &mut Option<SupervisedChild>,
4914    report: HealthReport,
4915    now_ms: u64,
4916) {
4917    let status = supervisor_health_status(report.status);
4918    let detail = report.detail.clone();
4919    let metrics = truncate_health_metrics(report.metrics);
4920    let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4921        state.health.status = status;
4922        state.health.last_probe_ms = Some(now_ms);
4923        state.health.detail = detail.clone();
4924        state.health.metrics = metrics.clone();
4925        state.health.consecutive_failures = 0;
4926    });
4927
4928    let action = match report.status {
4929        HealthStatus::Ok => return,
4930        HealthStatus::Degraded => runtime.health.on_degraded,
4931        HealthStatus::Failing => runtime.health.on_failing,
4932    };
4933    apply_l3_health_action(
4934        spec,
4935        runtime,
4936        registry,
4937        process_liveness,
4938        snapshot,
4939        child,
4940        status,
4941        detail.as_deref(),
4942        action,
4943        now_ms,
4944    )
4945    .await;
4946}
4947
4948#[allow(clippy::too_many_arguments)]
4949async fn handle_health_probe_failure(
4950    spec: &ModuleSpec,
4951    runtime: &SupervisorRuntimeConfig,
4952    registry: &Registry,
4953    process_liveness: &SupervisorProcessLiveness,
4954    snapshot: &SharedSnapshot,
4955    child: &mut Option<SupervisedChild>,
4956    err: HealthProbeError,
4957    now_ms: u64,
4958) {
4959    let threshold = runtime.health.failure_threshold.max(1);
4960    let mut failures = 0;
4961    // Carry the evidence class into the operator-visible detail. Without it,
4962    // "module did not answer within 5s" and "the control lane is gone" are two
4963    // prose strings in the same field, and the reader has to know the codebase to
4964    // tell which one is proof of anything.
4965    let detail = format!("[{}] {err}", err.label());
4966    let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4967        // A failed wire probe invalidates the last report, even before the
4968        // restart threshold. HTTP probes already mark failures as Failing.
4969        if state.spawned_protocol.unwrap_or(spec.protocol) == ModuleProtocol::Subc {
4970            state.health.status = SupervisorHealthStatus::Unknown;
4971        }
4972        state.health.last_probe_ms = Some(now_ms);
4973        state.health.consecutive_failures = state.health.consecutive_failures.saturating_add(1);
4974        state.health.detail = Some(detail.clone());
4975        state.health.metrics = None;
4976        failures = state.health.consecutive_failures;
4977    });
4978
4979    if failures < threshold {
4980        warn!(
4981            module_id = %spec.module_id,
4982            consecutive_failures = failures,
4983            threshold,
4984            evidence = err.label(),
4985            detail = %detail,
4986            "health.check probe failed"
4987        );
4988        return;
4989    }
4990
4991    let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4992        state.state = ModuleState::Unresponsive;
4993        state.health.status = SupervisorHealthStatus::Unresponsive;
4994    });
4995    // The evidence class is logged at the kill site because this is the line an
4996    // operator reads after an unexplained restart. A streak of `no-answer` under
4997    // machine load is the known false-positive shape; a `lane-dead` is not.
4998    if runtime.health.critical {
4999        error!(
5000            module_id = %spec.module_id,
5001            status = "unresponsive",
5002            evidence = err.label(),
5003            detail = %detail,
5004            "critical module health alert"
5005        );
5006    } else {
5007        warn!(
5008            module_id = %spec.module_id,
5009            status = "unresponsive",
5010            evidence = err.label(),
5011            detail = %detail,
5012            "module health threshold breached"
5013        );
5014    }
5015    if let Err(err) = health_restart_child(
5016        spec,
5017        runtime,
5018        registry,
5019        process_liveness,
5020        snapshot,
5021        child,
5022        SupervisorHealthStatus::Unresponsive,
5023        Some(&detail),
5024        now_ms,
5025    )
5026    .await
5027    {
5028        error!(module_id = %spec.module_id, error = %err, "health-triggered restart failed");
5029    }
5030}
5031
5032#[allow(clippy::too_many_arguments)]
5033async fn apply_l3_health_action(
5034    spec: &ModuleSpec,
5035    runtime: &SupervisorRuntimeConfig,
5036    registry: &Registry,
5037    process_liveness: &SupervisorProcessLiveness,
5038    snapshot: &SharedSnapshot,
5039    child: &mut Option<SupervisedChild>,
5040    status: SupervisorHealthStatus,
5041    detail: Option<&str>,
5042    action: HealthAction,
5043    now_ms: u64,
5044) {
5045    record_health_action(snapshot, &spec.module_id, action.to_string(), now_ms);
5046    match action {
5047        HealthAction::Report => {
5048            info!(
5049                module_id = %spec.module_id,
5050                status = ?status,
5051                detail,
5052                "module reported non-ok health"
5053            );
5054        }
5055        HealthAction::Alert => {
5056            error!(
5057                module_id = %spec.module_id,
5058                status = ?status,
5059                detail,
5060                "module health alert"
5061            );
5062        }
5063        HealthAction::Restart => {
5064            if let Err(err) = health_restart_child(
5065                spec,
5066                runtime,
5067                registry,
5068                process_liveness,
5069                snapshot,
5070                child,
5071                status,
5072                detail,
5073                now_ms,
5074            )
5075            .await
5076            {
5077                error!(module_id = %spec.module_id, error = %err, "health-triggered restart failed");
5078            }
5079        }
5080    }
5081}
5082
5083#[allow(clippy::too_many_arguments)]
5084async fn health_restart_child(
5085    spec: &ModuleSpec,
5086    runtime: &SupervisorRuntimeConfig,
5087    registry: &Registry,
5088    process_liveness: &SupervisorProcessLiveness,
5089    snapshot: &SharedSnapshot,
5090    child: &mut Option<SupervisedChild>,
5091    status: SupervisorHealthStatus,
5092    detail: Option<&str>,
5093    now_ms: u64,
5094) -> Result<(), SuperviseError> {
5095    let (enabled, schedule) = {
5096        let mut state = lock_snapshot(snapshot)?;
5097        let enabled = state.enabled;
5098        let schedule = if enabled {
5099            state.next_crash_restart(&runtime.restart_policy, Instant::now())
5100        } else {
5101            None
5102        };
5103        (enabled, schedule)
5104    };
5105
5106    if !enabled {
5107        return Err(SuperviseError::Disabled {
5108            module_id: spec.module_id.clone(),
5109        });
5110    }
5111
5112    if schedule.is_none() {
5113        record_health_action(snapshot, &spec.module_id, "failed".to_string(), now_ms);
5114        error!(
5115            module_id = %spec.module_id,
5116            status = ?status,
5117            detail,
5118            max_restarts = runtime.restart_policy.max_restarts,
5119            window_secs = runtime.restart_policy.window.as_secs(),
5120            reason = %runtime.restart_policy.budget_exhausted_detail(),
5121            "health restart budget exhausted; marking module failed"
5122        );
5123        let stop_notice = begin_forwarding_drain_if_configured(
5124            spec,
5125            runtime,
5126            registry,
5127            snapshot,
5128            Some(true),
5129            RouteCloseReason::Disable,
5130        )
5131        .await?;
5132        update_snapshot(snapshot, Some(&spec.module_id), |state| {
5133            state.drain_disposition_detail = Some(runtime.restart_policy.budget_exhausted_detail());
5134        })?;
5135        drain_optional_child(
5136            &spec.module_id,
5137            spec.protocol,
5138            stop_notice,
5139            registry,
5140            runtime.forwarding.as_deref(),
5141            snapshot,
5142            &runtime.terminal_ring,
5143            &runtime.spawn_events,
5144            child,
5145            runtime.drain_timeout,
5146            ModuleState::Failed,
5147            Some(true),
5148        )
5149        .await?;
5150        process_liveness.untrack_if_current(&spec.module_id, snapshot);
5151        return Ok(());
5152    }
5153
5154    let schedule = schedule.expect("a health restart must have a crash-restart schedule");
5155    let mut restart_count = 0;
5156    update_snapshot(snapshot, Some(&spec.module_id), |state| {
5157        restart_count = state.crash_restarts.len();
5158        state.state = ModuleState::Unresponsive;
5159        state.health.status = status;
5160        state.health.last_action = Some(HealthAction::Restart.to_string());
5161        state.health.last_action_ms = Some(now_ms);
5162    })?;
5163    warn!(
5164        module_id = %spec.module_id,
5165        status = ?status,
5166        detail,
5167        restart_count,
5168        restart_in_window = schedule.restart_in_window,
5169        delay_ms = schedule.delay.as_millis() as u64,
5170        "health-triggered module restart"
5171    );
5172
5173    let stop_notice = begin_forwarding_drain_if_configured(
5174        spec,
5175        runtime,
5176        registry,
5177        snapshot,
5178        Some(true),
5179        RouteCloseReason::Restart,
5180    )
5181    .await?;
5182    drain_optional_child(
5183        &spec.module_id,
5184        spec.protocol,
5185        stop_notice,
5186        registry,
5187        runtime.forwarding.as_deref(),
5188        snapshot,
5189        &runtime.terminal_ring,
5190        &runtime.spawn_events,
5191        child,
5192        runtime.drain_timeout,
5193        ModuleState::Restarting,
5194        Some(true),
5195    )
5196    .await?;
5197    schedule_respawn(
5198        runtime,
5199        snapshot,
5200        &spec.module_id,
5201        schedule.delay,
5202        RespawnKind::Spawn,
5203    )
5204}
5205
5206fn cancel_deferred_reload(runtime: &SupervisorRuntimeConfig, module_id: &str, reason: &str) {
5207    if let Some(reply) = runtime
5208        .deferred_reload_reply
5209        .lock()
5210        .unwrap_or_else(|p| p.into_inner())
5211        .take()
5212    {
5213        let _ = reply.send(Err(SuperviseError::ReloadFailed {
5214            module_id: module_id.to_string(),
5215            reason: reason.to_string(),
5216        }));
5217    }
5218}
5219
5220fn schedule_respawn(
5221    runtime: &SupervisorRuntimeConfig,
5222    snapshot: &SharedSnapshot,
5223    module_id: &str,
5224    delay: Duration,
5225    kind: RespawnKind,
5226) -> Result<(), SuperviseError> {
5227    cancel_deferred_reload(runtime, module_id, "respawn superseded by another restart");
5228    update_snapshot(snapshot, Some(module_id), |state| {
5229        state.respawn_pending = true
5230    })?;
5231    *runtime
5232        .scheduled_respawn
5233        .lock()
5234        .unwrap_or_else(|p| p.into_inner()) = Some(PendingRespawn {
5235        deadline: Instant::now() + delay,
5236        kind,
5237    });
5238    Ok(())
5239}
5240
5241fn record_health_action(snapshot: &SharedSnapshot, module_id: &str, action: String, now_ms: u64) {
5242    let _ = update_snapshot(snapshot, Some(module_id), |state| {
5243        state.health.last_action = Some(action);
5244        state.health.last_action_ms = Some(now_ms);
5245    });
5246}
5247
5248fn supervisor_health_status(status: HealthStatus) -> SupervisorHealthStatus {
5249    match status {
5250        HealthStatus::Ok => SupervisorHealthStatus::Ok,
5251        HealthStatus::Degraded => SupervisorHealthStatus::Degraded,
5252        HealthStatus::Failing => SupervisorHealthStatus::Failing,
5253    }
5254}
5255
5256/// Caps the metrics blob stored in the cached supervisor snapshot, which is
5257/// returned to every `supervisor.list` and `supervisor.health` caller.
5258///
5259/// This cap is deliberately NOT applied on the one-shot `supervisor.health_probe`
5260/// path: that request exists to return a module's complete metrics object, and
5261/// `ck health <module-id>` documents it as the way to see what the cached view
5262/// truncates. The asymmetry is the feature.
5263///
5264/// So a new caller must decide which side it is on rather than assume the cap is
5265/// universal. Reaching for it on a fresh-probe path would silently reintroduce
5266/// the truncation that path exists to avoid.
5267fn truncate_health_metrics(metrics: Option<Value>) -> Option<Value> {
5268    let metrics = metrics?;
5269    match serde_json::to_vec(&metrics) {
5270        Ok(encoded) if encoded.len() > MAX_HEALTH_METRICS_BYTES => Some(serde_json::json!({
5271            "truncated": true,
5272            "original_bytes": encoded.len(),
5273        })),
5274        Ok(_) | Err(_) => Some(metrics),
5275    }
5276}
5277
5278/// Spread health probes so a fleet-wide restart does not converge them.
5279///
5280/// The delay is derived from the module id and probe index rather than a random
5281/// source, so it is deterministic per module: a module keeps its own offset
5282/// across daemon restarts instead of re-rolling into a collision.
5283fn jittered_health_delay(module_id: &str, probe_index: u64, cadence: Duration) -> Duration {
5284    if cadence.is_zero() {
5285        return Duration::ZERO;
5286    }
5287    let cadence_ms = cadence.as_millis() as u64;
5288    // This early return is REDUNDANT, deliberately, and a mutation run will show
5289    // it surviving removal. Recording why here so the next person to notice does
5290    // not have to re-derive it:
5291    //
5292    // - It is unreachable in practice. `positive_millis` in daemon_config rejects
5293    //   a zero cadence and builds the Duration from whole milliseconds, so a
5294    //   sub-millisecond cadence cannot come from config.
5295    // - Even if reached it changes no answer. The `.max(1)` below makes the span
5296    //   1, and `hash % 1` is 0, so the fall-through returns `cadence` unchanged
5297    //   -- exactly what this returns.
5298    //
5299    // Kept as a guard against a future widening of the config parser (accepting
5300    // microseconds, say), which would make the sub-millisecond case reachable.
5301    // The `.max(1)` is the load-bearing half TODAY: remove it and the modulo
5302    // divides by zero. Remove this and nothing changes.
5303    if cadence_ms == 0 {
5304        return cadence;
5305    }
5306    // Note that this never returns less than one cadence, including for the FIRST
5307    // probe. So a freshly registered module reports health `unknown` for a full
5308    // cadence plus jitter -- 30-33s at the default -- no matter how quickly it is
5309    // ready to answer.
5310    //
5311    // That is a property of the supervisor's schedule, not of any module: an
5312    // operator watching a restart sees `unknown` and cannot tell it from a module
5313    // that is slow to warm. Measured on two unrelated modules, both flipping to
5314    // `ok` between 22s and 32s after restart.
5315    //
5316    // Left as-is because spreading the first probe is what keeps a fleet-wide
5317    // restart from firing fourteen simultaneous probes into a cold machine. The
5318    // alternative -- probe at t+0 and jitter only from the second onward -- trades
5319    // that thundering herd for a faster first reading.
5320    let jitter_span = (cadence_ms / 10).max(1);
5321    let hash = module_id.as_bytes().iter().fold(
5322        probe_index.wrapping_mul(0x9E37_79B9_7F4A_7C15),
5323        |acc, byte| {
5324            acc.wrapping_mul(1099511628211)
5325                .wrapping_add(u64::from(*byte))
5326        },
5327    );
5328    cadence + Duration::from_millis(hash % jitter_span)
5329}
5330
5331#[cfg(test)]
5332mod tests {
5333    use super::*;
5334
5335    #[test]
5336    fn readding_a_module_clears_its_rescan_removal_tombstone() {
5337        let handle = SupervisorHandle::new();
5338        let module_id = "readded-tombstone";
5339        handle.record_rescan_removal(module_id);
5340        assert!(handle.removal_tombstone_age_ms(module_id).is_some());
5341
5342        handle.apply_identity_configuration(&ModuleSpec {
5343            module_id: module_id.to_string(),
5344            program: PathBuf::from("/test/module"),
5345            args: Vec::new(),
5346            env: Vec::new(),
5347            reserved: false,
5348            reserved_prefixes: Vec::new(),
5349            protocol: ModuleProtocol::Subc,
5350            overlap: Default::default(),
5351        });
5352
5353        assert!(
5354            handle.removal_tombstone_age_ms(module_id).is_none(),
5355            "a re-added module must not retain a stale removal tombstone"
5356        );
5357    }
5358
5359    /// What one module's owner looked like from the control plane at the
5360    /// instant after its first process was spawned.
5361    #[derive(Debug, PartialEq, Eq)]
5362    struct OwnerInSpawnWindow {
5363        module_id: String,
5364        configured: bool,
5365        on_roster: bool,
5366        admission_refusal: Option<&'static str>,
5367    }
5368
5369    /// A supervised module's process can connect, register, sync its scopes
5370    /// and describe them as soon as it is spawned, which is BEFORE the
5371    /// supervisor puts the module on the roster. In that window the owner must
5372    /// already read as configured, so a scoped `route.open` against it is
5373    /// refused as retryable `scope_not_synced` and not as terminal
5374    /// `scope_not_live` ("will never sync").
5375    ///
5376    /// The hook runs in exactly that window on every path that takes on a new
5377    /// module, so no race with a real child is needed: `on_roster: false`
5378    /// proves each observation was taken before the roster insert.
5379    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5380    async fn a_new_module_is_configured_before_its_first_process_can_register() {
5381        use crate::scopes::ScopeTable;
5382        use subc_protocol::{error_codes, scope::ScopeSelector, Principal};
5383
5384        let dir = cortexkit_test_support::ScratchDir::new("configured-before-spawn");
5385        let stub = |module_id: &str, program: PathBuf| ModuleSpec {
5386            module_id: module_id.to_string(),
5387            program,
5388            args: Vec::new(),
5389            env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
5390                .into_iter()
5391                .map(|key| (key.to_string(), dir.path().display().to_string()))
5392                .chain([("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string())])
5393                .collect(),
5394            reserved: false,
5395            reserved_prefixes: Vec::new(),
5396            protocol: ModuleProtocol::Subc,
5397            overlap: Default::default(),
5398        };
5399        let live = super::terminal_history_tests::fake_aft_stub_path();
5400        let missing = dir.path().join("definitely-missing-module");
5401
5402        let handle = SupervisorHandle::new();
5403        let mut supervisor =
5404            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5405                .with_handle(handle.clone());
5406        let observed = Arc::new(Mutex::new(Vec::<OwnerInSpawnWindow>::new()));
5407        let hook_handle = handle.clone();
5408        let hook_observed = Arc::clone(&observed);
5409        supervisor.test_after_first_spawn = AfterFirstSpawnHook(Some(Arc::new(move |module_id| {
5410            // Exactly what the control plane computes for a scoped route.open
5411            // naming this module as the owner of a scope it has not synced.
5412            let configured = hook_handle.is_configured(module_id);
5413            let selector = ScopeSelector {
5414                owner: Principal::Reserved {
5415                    module_id: module_id.to_string(),
5416                },
5417                scope_ref: "s".to_string(),
5418                scope_epoch: Some(1),
5419            };
5420            let carrier = Principal::Reserved {
5421                module_id: "carrier".to_string(),
5422            };
5423            let admission_refusal = match ScopeTable::new(Vec::<String>::new())
5424                .admit(&carrier, module_id, &selector, configured)
5425            {
5426                Ok(_) => None,
5427                Err(refusal) => Some(refusal.code),
5428            };
5429            hook_observed.lock().unwrap().push(OwnerInSpawnWindow {
5430                module_id: module_id.to_string(),
5431                configured,
5432                on_roster: hook_handle.get(module_id).is_some(),
5433                admission_refusal,
5434            });
5435        })));
5436
5437        let plain = supervisor.spawn(stub("plain", live.clone())).unwrap();
5438        let configured = supervisor
5439            .supervise_configured(stub("configured", live.clone()), true)
5440            .unwrap();
5441        let with_health = supervisor
5442            .supervise_configured_with_health(
5443                stub("with-health", live.clone()),
5444                true,
5445                HealthConfig::default(),
5446                None,
5447                RestartPolicy::default(),
5448            )
5449            .unwrap();
5450        // The failed-spawn path still puts the module on the roster (as
5451        // failed), so it is configured throughout.
5452        let failed = supervisor
5453            .supervise_configured_with_health(
5454                stub("failed-spawn", missing.clone()),
5455                true,
5456                HealthConfig::default(),
5457                None,
5458                RestartPolicy::default(),
5459            )
5460            .unwrap();
5461        // A failed plain `spawn` puts nothing on the roster, so its mark is
5462        // taken back once the spawn has failed.
5463        assert!(supervisor.spawn(stub("spawn-error", missing)).is_err());
5464
5465        let expected = [
5466            "plain",
5467            "configured",
5468            "with-health",
5469            "failed-spawn",
5470            "spawn-error",
5471        ]
5472        .into_iter()
5473        .map(|module_id| OwnerInSpawnWindow {
5474            module_id: module_id.to_string(),
5475            configured: true,
5476            on_roster: false,
5477            admission_refusal: Some(error_codes::SCOPE_NOT_SYNCED),
5478        })
5479        .collect::<Vec<_>>();
5480        assert_eq!(*observed.lock().unwrap(), expected);
5481
5482        for module_id in ["plain", "configured", "with-health", "failed-spawn"] {
5483            assert!(
5484                handle.get(module_id).is_some(),
5485                "{module_id} is on the roster"
5486            );
5487            assert!(
5488                handle.is_configured(module_id),
5489                "{module_id} stays configured"
5490            );
5491        }
5492        assert!(handle.get("spawn-error").is_none());
5493        assert!(
5494            !handle.is_configured("spawn-error"),
5495            "a plain spawn that failed must not leave its module marked configured"
5496        );
5497
5498        // Leaving the roster clears the mark with it.
5499        handle.retire("failed-spawn");
5500        assert!(!handle.is_configured("failed-spawn"));
5501
5502        for module in [plain, configured, with_health] {
5503            module.stop().await.unwrap();
5504        }
5505        drop(failed);
5506    }
5507
5508    fn stale_process_snapshot(state: ModuleState, enabled: bool) -> SharedSnapshot {
5509        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(state, enabled)));
5510        update_snapshot(&snapshot, Some("stale-process-facts"), |snapshot| {
5511            snapshot.process_alive = true;
5512            snapshot.pid = Some(41);
5513            snapshot.spawned_at_ms = Some(42);
5514            snapshot.spawned_from = Some(PathBuf::from("/spawned/module"));
5515            snapshot.spawned_file_identity = Some(SpawnedFileIdentity {
5516                device: 43,
5517                inode: 44,
5518                #[cfg(windows)]
5519                windows_image: None,
5520            });
5521        })
5522        .unwrap();
5523        snapshot
5524    }
5525
5526    fn assert_snapshot_process_facts_cleared(snapshot: &SharedSnapshot) {
5527        let snapshot = lock_snapshot(snapshot).unwrap();
5528        assert!(!snapshot.process_alive);
5529        assert_eq!(snapshot.pid, None);
5530        assert_eq!(snapshot.spawned_at_ms, None);
5531        assert_eq!(snapshot.spawned_from, None);
5532        assert_eq!(snapshot.spawned_file_identity, None);
5533    }
5534
5535    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5536    async fn failed_enable_spawn_clears_preexisting_current_process_facts() {
5537        let supervisor =
5538            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5539        let mut runtime = supervisor.runtime_config();
5540        runtime.test_seed_stale_facts_before_enable_spawn = true;
5541        let snapshot = stale_process_snapshot(ModuleState::Disabled, false);
5542        let mut child = None;
5543        let spec = ModuleSpec {
5544            module_id: "failed-enable-clears-facts".to_string(),
5545            program: PathBuf::from("/definitely/missing/failed-enable-module"),
5546            args: Vec::new(),
5547            env: Vec::new(),
5548            reserved: false,
5549            reserved_prefixes: Vec::new(),
5550            protocol: ModuleProtocol::Subc,
5551            overlap: Default::default(),
5552        };
5553
5554        let result = set_child_enabled(
5555            &spec,
5556            &runtime,
5557            &supervisor.registry,
5558            &supervisor.process_liveness,
5559            &snapshot,
5560            &mut child,
5561            true,
5562        )
5563        .await;
5564
5565        assert!(matches!(result, Err(SuperviseError::Spawn { .. })));
5566        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
5567        assert_snapshot_process_facts_cleared(&snapshot);
5568    }
5569
5570    #[tokio::test]
5571    async fn start_revives_stranded_restarting_but_not_pending_backoff() {
5572        let supervisor =
5573            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5574        let runtime = supervisor.runtime_config();
5575        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(
5576            ModuleState::Restarting,
5577            true,
5578        )));
5579        let spec = ModuleSpec {
5580            module_id: "start-stranded-restarting".to_string(),
5581            program: super::terminal_history_tests::fake_aft_stub_path(),
5582            args: Vec::new(),
5583            env: vec![("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string())],
5584            reserved: false,
5585            reserved_prefixes: Vec::new(),
5586            protocol: ModuleProtocol::None,
5587            overlap: Default::default(),
5588        };
5589        let mut child = None;
5590        lock_snapshot(&snapshot).unwrap().respawn_pending = true;
5591        assert!(!super::set_child_enabled(
5592            &spec,
5593            &runtime,
5594            &Registry::default(),
5595            &supervisor.process_liveness,
5596            &snapshot,
5597            &mut child,
5598            true
5599        )
5600        .await
5601        .unwrap());
5602        assert!(child.is_none());
5603        lock_snapshot(&snapshot).unwrap().respawn_pending = false;
5604        assert!(super::set_child_enabled(
5605            &spec,
5606            &runtime,
5607            &Registry::default(),
5608            &supervisor.process_liveness,
5609            &snapshot,
5610            &mut child,
5611            true
5612        )
5613        .await
5614        .unwrap());
5615        assert_eq!(
5616            lock_snapshot(&snapshot).unwrap().state,
5617            ModuleState::Running
5618        );
5619        let mut child = child.unwrap();
5620        child.start_kill().unwrap();
5621        child.wait().await.unwrap();
5622    }
5623
5624    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5625    async fn failed_reload_spawn_clears_current_process_facts() {
5626        let supervisor =
5627            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5628        let mut runtime = supervisor.runtime_config();
5629        runtime.restart_policy = RestartPolicy::new(0, Duration::ZERO);
5630        let snapshot = stale_process_snapshot(ModuleState::Running, true);
5631        let mut child = None;
5632        let spec = ModuleSpec {
5633            module_id: "failed-reload-clears-facts".to_string(),
5634            program: PathBuf::from("/unused/failed-reload-module"),
5635            args: Vec::new(),
5636            env: Vec::new(),
5637            reserved: false,
5638            reserved_prefixes: Vec::new(),
5639            protocol: ModuleProtocol::Subc,
5640            overlap: Default::default(),
5641        };
5642
5643        let result = handle_reload_spawn_failure(
5644            &spec,
5645            &runtime,
5646            &supervisor.process_liveness,
5647            &snapshot,
5648            &mut child,
5649            "forced reload spawn failure".to_string(),
5650        )
5651        .await;
5652
5653        assert!(matches!(result, Err(SuperviseError::ReloadFailed { .. })));
5654        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
5655        assert_snapshot_process_facts_cleared(&snapshot);
5656    }
5657
5658    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5659    async fn dropping_a_module_with_an_active_monitor_clears_current_process_facts() {
5660        let supervisor =
5661            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5662        let snapshot = stale_process_snapshot(ModuleState::Running, true);
5663        let module = supervisor.supervised_module(
5664            ModuleSpec {
5665                module_id: "drop-clears-facts".to_string(),
5666                program: PathBuf::from("/unused/drop-module"),
5667                args: Vec::new(),
5668                env: Vec::new(),
5669                reserved: false,
5670                reserved_prefixes: Vec::new(),
5671                protocol: ModuleProtocol::Subc,
5672                overlap: Default::default(),
5673            },
5674            supervisor.runtime_config(),
5675            Arc::clone(&snapshot),
5676            None,
5677        );
5678        assert!(!module
5679            .inner
5680            .monitor
5681            .lock()
5682            .unwrap()
5683            .as_ref()
5684            .unwrap()
5685            .is_finished());
5686
5687        drop(module);
5688
5689        assert_eq!(
5690            lock_snapshot(&snapshot).unwrap().state,
5691            ModuleState::Stopped
5692        );
5693        assert_snapshot_process_facts_cleared(&snapshot);
5694    }
5695
5696    #[cfg(unix)]
5697    #[tokio::test]
5698    async fn rescan_preserves_running_protocol_until_respawn() {
5699        let dir = cortexkit_test_support::ScratchDir::new("rescan-protocol");
5700        let initial = ModuleSpec {
5701            module_id: "rescan-protocol".into(),
5702            program: PathBuf::from("/bin/sleep"),
5703            args: vec!["60".into()],
5704            env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
5705                .into_iter()
5706                .map(|key| (key.into(), dir.path().to_string_lossy().into_owned()))
5707                .collect(),
5708            reserved: false,
5709            reserved_prefixes: vec![],
5710            protocol: ModuleProtocol::None,
5711            overlap: Default::default(),
5712        };
5713        let supervisor =
5714            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5715        let module = supervisor.spawn(initial.clone()).unwrap();
5716        assert!(module.status().unwrap().live);
5717        let mut next = initial;
5718        next.protocol = ModuleProtocol::Subc;
5719        module
5720            .update_configuration(next.clone(), HealthConfig::default(), None)
5721            .await
5722            .unwrap();
5723        assert!(
5724            module.status().unwrap().live,
5725            "rescan must not require HELLO from the old non-wire process"
5726        );
5727        let runtime = supervisor.runtime_config();
5728        let action = on_child_exit(
5729            &next,
5730            RestartPolicy::default(),
5731            &supervisor.registry,
5732            &module.inner.snapshot,
5733            &runtime.terminal_ring,
5734            &runtime.spawn_events,
5735            &runtime.child_roster,
5736            ExitReport {
5737                kind: ExitKind::Clean,
5738                code: Some(0),
5739                signal: None,
5740                at_ms: unix_ms_now(),
5741            },
5742        )
5743        .await;
5744        assert!(
5745            matches!(action, NextAction::Restart { .. }),
5746            "the old non-wire process's clean exit must restart"
5747        );
5748        module.drain().await.unwrap();
5749    }
5750
5751    #[cfg(unix)]
5752    #[tokio::test]
5753    async fn orphan_identity_matches_path_names_and_shebang_interpreters() {
5754        use std::os::unix::fs::PermissionsExt;
5755        let dir = cortexkit_test_support::ScratchDir::new("spawn-image-identity");
5756        let script = dir.join("module.sh");
5757        std::fs::write(&script, "#!/bin/sh\nwhile :; do sleep 0.1; done\n").unwrap();
5758        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700)).unwrap();
5759        let record_path = dir.join("live-children.json");
5760        let supervisor =
5761            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5762                .with_live_children_record(&record_path);
5763        for (program, args) in [
5764            (PathBuf::from("sleep"), vec!["60".into()]),
5765            (script, vec![]),
5766        ] {
5767            let spec = ModuleSpec {
5768                module_id: "image-identity".into(),
5769                program: program.clone(),
5770                args,
5771                env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
5772                    .into_iter()
5773                    .map(|key| (key.into(), dir.to_string_lossy().into_owned()))
5774                    .collect(),
5775                reserved: false,
5776                reserved_prefixes: vec![],
5777                protocol: ModuleProtocol::None,
5778                overlap: Default::default(),
5779            };
5780            let module = supervisor.spawn(spec).unwrap();
5781            #[cfg(target_os = "macos")]
5782            {
5783                // SETEXEC confirmation is asynchronous; the orphan record must
5784                // identify the final image, never the intermediate trampoline.
5785                let deadline = Instant::now() + Duration::from_secs(5);
5786                while crate::live_children::read_record(&record_path)
5787                    .unwrap()
5788                    .iter()
5789                    .all(|entry| entry.executable.is_none())
5790                {
5791                    assert!(Instant::now() < deadline, "module image was not confirmed");
5792                    tokio::time::sleep(Duration::from_millis(5)).await;
5793                }
5794            }
5795            let entry = crate::live_children::read_record(&record_path)
5796                .unwrap()
5797                .pop()
5798                .unwrap();
5799            let observed = subc_os::Process::open(entry.pid)
5800                .unwrap()
5801                .unwrap()
5802                .observe()
5803                .unwrap();
5804            let verdict = crate::live_children::identity_verdict(&entry, entry.pid, &observed);
5805            module.drain().await.unwrap();
5806            assert_eq!(
5807                verdict,
5808                crate::live_children::IdentityVerdict::Matches,
5809                "program {program:?}: recorded {entry:?}, observed {observed:?}"
5810            );
5811        }
5812    }
5813
5814    #[cfg(unix)]
5815    fn http_fixture(
5816        dir: &std::path::Path,
5817        url: &str,
5818        threshold: u32,
5819    ) -> crate::daemon_config::ConfiguredModule {
5820        let path = dir.join("subc.jsonc");
5821        std::fs::write(&path, serde_json::json!({"version": 1, "modules": {"http-process": {
5822            "program": "/bin/sleep", "args": ["60"], "protocol": "none",
5823            "env": {"XDG_DATA_HOME":dir,"XDG_RUNTIME_DIR":dir,"XDG_CONFIG_HOME":dir},
5824            "health": {"http":url,"cadence_ms":20,"deadline_ms":200,"failure_threshold":threshold},
5825            "restart": {"backoff_ms":1,"max_backoff_ms":1}, "drain_timeout_ms":10
5826        }}}).to_string()).unwrap();
5827        crate::daemon_config::load(&path)
5828            .unwrap()
5829            .unwrap()
5830            .modules
5831            .pop()
5832            .unwrap()
5833    }
5834
5835    #[cfg(unix)]
5836    async fn wait_http_health(module: &SupervisedModule, status: SupervisorHealthStatus) {
5837        timeout(Duration::from_secs(5), async {
5838            loop {
5839                if module.status().unwrap().health.status == status {
5840                    break;
5841                }
5842                sleep(Duration::from_millis(5)).await;
5843            }
5844        })
5845        .await
5846        .unwrap_or_else(|_| {
5847            panic!(
5848                "expected {status:?}, got {:?}",
5849                module.status().unwrap().health
5850            )
5851        });
5852    }
5853
5854    #[cfg(unix)]
5855    #[tokio::test]
5856    async fn http_health_status_flips_ok_failing_ok() {
5857        use tokio::io::{AsyncReadExt, AsyncWriteExt};
5858        let dir = cortexkit_test_support::ScratchDir::new("http-status-flips");
5859        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5860        let status = Arc::new(std::sync::atomic::AtomicU16::new(200));
5861        let serving_status = status.clone();
5862        let url = format!("http://{}/healthz", listener.local_addr().unwrap());
5863        let server = tokio::spawn(async move {
5864            loop {
5865                let (mut stream, _) = listener.accept().await.unwrap();
5866                let mut request = [0u8; 2048];
5867                let count = stream.read(&mut request).await.unwrap();
5868                assert!(count > 0, "a probe must send an HTTP request");
5869                let code = serving_status.load(std::sync::atomic::Ordering::SeqCst);
5870                let body = if code == 200 {
5871                    "ready"
5872                } else {
5873                    "{\"status\":\"unavailable\",\"error\":\"scratch failure\"}"
5874                };
5875                let response = format!("HTTP/1.1 {code} scratch\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
5876                let _ = stream.write_all(response.as_bytes()).await;
5877            }
5878        });
5879        let configured = http_fixture(&dir, &url, 1000);
5880        let module =
5881            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5882                .supervise_configured_with_health(
5883                    configured.module_spec(),
5884                    true,
5885                    configured.health,
5886                    configured.drain_timeout_ms,
5887                    configured.restart,
5888                )
5889                .unwrap();
5890        wait_http_health(&module, SupervisorHealthStatus::Ok).await;
5891        status.store(503, std::sync::atomic::Ordering::SeqCst);
5892        wait_http_health(&module, SupervisorHealthStatus::Failing).await;
5893        assert!(module
5894            .status()
5895            .unwrap()
5896            .health
5897            .detail
5898            .unwrap()
5899            .contains("scratch failure"));
5900        status.store(200, std::sync::atomic::Ordering::SeqCst);
5901        wait_http_health(&module, SupervisorHealthStatus::Ok).await;
5902        assert_eq!(module.status().unwrap().health.consecutive_failures, 0);
5903        let before = module.status().unwrap();
5904        let (spec, mut health) = module.configuration().unwrap();
5905        health.http = None;
5906        module
5907            .update_configuration(spec.clone(), health.clone(), Some(10))
5908            .await
5909            .unwrap();
5910        wait_http_health(&module, SupervisorHealthStatus::Unknown).await;
5911        health.http = Some(url);
5912        module
5913            .update_configuration(spec, health, Some(10))
5914            .await
5915            .unwrap();
5916        wait_http_health(&module, SupervisorHealthStatus::Ok).await;
5917        assert_eq!(
5918            module.status().unwrap().pid,
5919            before.pid,
5920            "changing a probe must apply live, not restart its process"
5921        );
5922        let (spec, mut health) = module.configuration().unwrap();
5923        health.failure_threshold = 2;
5924        module
5925            .update_configuration(spec, health, Some(10))
5926            .await
5927            .unwrap();
5928        status.store(503, std::sync::atomic::Ordering::SeqCst);
5929        timeout(Duration::from_secs(5), async {
5930            while module.status().unwrap().spawn_generation == before.spawn_generation {
5931                sleep(Duration::from_millis(5)).await;
5932            }
5933        })
5934        .await
5935        .expect("sustained HTTP 503 responses must trigger the health restart policy");
5936        module.drain().await.unwrap();
5937        server.abort();
5938    }
5939
5940    #[cfg(unix)]
5941    #[tokio::test]
5942    async fn http_health_no_listener_restarts_after_consecutive_failures() {
5943        let dir = cortexkit_test_support::ScratchDir::new("http-refused");
5944        let unused = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5945        let url = format!("http://{}/healthz", unused.local_addr().unwrap());
5946        drop(unused);
5947        let configured = http_fixture(&dir, &url, 2);
5948        let module =
5949            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5950                .supervise_configured_with_health(
5951                    configured.module_spec(),
5952                    true,
5953                    configured.health,
5954                    configured.drain_timeout_ms,
5955                    configured.restart,
5956                )
5957                .unwrap();
5958        let before = module.status().unwrap().spawn_generation;
5959        timeout(Duration::from_secs(5), async {
5960            loop {
5961                let status = module.status().unwrap();
5962                if status.spawn_generation > before {
5963                    assert!(status.lifetime_restarts > 0);
5964                    break;
5965                }
5966                sleep(Duration::from_millis(5)).await;
5967            }
5968        })
5969        .await
5970        .expect("sustained HTTP refusal must trigger the health restart policy");
5971        module.drain().await.unwrap();
5972    }
5973
5974    #[cfg(unix)]
5975    #[tokio::test]
5976    async fn http_health_timeout_honours_deadline() {
5977        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5978        let url = format!("http://{}/healthz", listener.local_addr().unwrap());
5979        let server = tokio::spawn(async move {
5980            let _held = listener.accept().await.unwrap();
5981            std::future::pending::<()>().await;
5982        });
5983        let error = timeout(
5984            Duration::from_secs(1),
5985            probe_http_health(&url, Duration::from_millis(10)),
5986        )
5987        .await
5988        .expect("the probe must enforce its own deadline")
5989        .unwrap_err();
5990        server.abort();
5991        assert!(error.to_string().contains("timed out"));
5992    }
5993
5994    #[cfg(unix)]
5995    #[tokio::test]
5996    async fn http_health_localhost_reaches_an_ipv6_only_listener() {
5997        use tokio::io::{AsyncReadExt, AsyncWriteExt};
5998        let listener = tokio::net::TcpListener::bind("[::1]:0").await.unwrap();
5999        let url = format!(
6000            "http://localhost:{}/healthz",
6001            listener.local_addr().unwrap().port()
6002        );
6003        let server = tokio::spawn(async move {
6004            let (mut stream, _) = listener.accept().await.unwrap();
6005            let mut request = [0u8; 2048];
6006            assert!(stream.read(&mut request).await.unwrap() > 0);
6007            stream
6008                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
6009                .await
6010                .unwrap();
6011        });
6012        // The deadline only bounds a hang. A probe that never tried the IPv6
6013        // address would be refused on 127.0.0.1 and fail at once, so a longer
6014        // deadline does not weaken the assertion; one second timed out under a
6015        // loaded parallel test run.
6016        assert_eq!(
6017            probe_http_health(&url, Duration::from_secs(10))
6018                .await
6019                .unwrap()
6020                .status,
6021            HealthStatus::Ok
6022        );
6023        server.await.unwrap();
6024    }
6025
6026    #[cfg(unix)]
6027    #[tokio::test]
6028    async fn http_health_timeout_keeps_partial_status_and_body() {
6029        use tokio::io::{AsyncReadExt, AsyncWriteExt};
6030        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6031        let url = format!("http://{}/healthz", listener.local_addr().unwrap());
6032        let server = tokio::spawn(async move {
6033            let (mut stream, _) = listener.accept().await.unwrap();
6034            let mut request = [0u8; 2048];
6035            assert!(stream.read(&mut request).await.unwrap() > 0);
6036            stream
6037                .write_all(
6038                    b"HTTP/1.1 503 Unavailable\r\nContent-Length: 100\r\n\r\npartial diagnostic",
6039                )
6040                .await
6041                .unwrap();
6042            std::future::pending::<()>().await;
6043        });
6044        let error = probe_http_health(&url, Duration::from_secs(1))
6045            .await
6046            .unwrap_err()
6047            .to_string();
6048        server.abort();
6049        assert!(
6050            error.contains("timed out")
6051                && error.contains("503 Unavailable")
6052                && error.contains("partial diagnostic"),
6053            "{error}"
6054        );
6055    }
6056
6057    #[cfg(unix)]
6058    #[tokio::test]
6059    async fn http_health_diagnostic_body_is_bounded_to_200_bytes() {
6060        use tokio::io::{AsyncReadExt, AsyncWriteExt};
6061        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6062        let url = format!("http://{}/healthz", listener.local_addr().unwrap());
6063        let server = tokio::spawn(async move {
6064            let (mut stream, _) = listener.accept().await.unwrap();
6065            let mut request = [0u8; 2048];
6066            assert!(stream.read(&mut request).await.unwrap() > 0);
6067            let body = format!("{}not-in-diagnostic", "x".repeat(400));
6068            let response = format!(
6069                "HTTP/1.1 503 Unavailable\r\nContent-Length: {}\r\n\r\n{body}",
6070                body.len()
6071            );
6072            stream.write_all(response.as_bytes()).await.unwrap();
6073        });
6074        let error = probe_http_health(&url, Duration::from_secs(1))
6075            .await
6076            .unwrap_err()
6077            .to_string();
6078        server.await.unwrap();
6079        assert!(error.ends_with(&"x".repeat(200)), "{error}");
6080        assert_eq!(error.split(": ").last().unwrap().len(), 200);
6081        assert!(!error.contains("not-in-diagnostic"));
6082    }
6083
6084    #[cfg(unix)]
6085    #[tokio::test]
6086    async fn http_health_real_nats_server_monitoring() {
6087        if std::process::Command::new("nats-server")
6088            .arg("--version")
6089            .env("XDG_DATA_HOME", std::env::temp_dir())
6090            .env("XDG_RUNTIME_DIR", std::env::temp_dir())
6091            .env("XDG_CONFIG_HOME", std::env::temp_dir())
6092            .output()
6093            .is_err()
6094        {
6095            eprintln!(
6096                "skipped: http_health_real_nats_server_monitoring (nats-server is not on PATH)"
6097            );
6098            return;
6099        }
6100        let dir = cortexkit_test_support::ScratchDir::new("nats-http-monitoring");
6101        let monitor = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6102        let port = monitor.local_addr().unwrap().port();
6103        let client = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6104        let client_port = client.local_addr().unwrap().port();
6105        let config = dir.join("server.conf");
6106        std::fs::write(&config, format!("listen: 127.0.0.1:{client_port}\nhttp: 127.0.0.1:{port}\njetstream {{ store_dir: \"{}\" }}\n", dir.join("jetstream").display())).unwrap();
6107        let mut configured = http_fixture(&dir, &format!("http://127.0.0.1:{port}/healthz"), 1000);
6108        configured.program = PathBuf::from("nats-server");
6109        configured.args = vec!["-c".into(), config.to_string_lossy().into_owned()];
6110        drop(monitor);
6111        drop(client);
6112        let module =
6113            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
6114                .supervise_configured_with_health(
6115                    configured.module_spec(),
6116                    true,
6117                    configured.health,
6118                    configured.drain_timeout_ms,
6119                    configured.restart,
6120                )
6121                .unwrap();
6122        wait_http_health(&module, SupervisorHealthStatus::Ok).await;
6123        module.drain().await.unwrap();
6124    }
6125
6126    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
6127    async fn configuration_update_does_not_replace_captured_running_process_facts() {
6128        let supervisor =
6129            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
6130        let snapshot = stale_process_snapshot(ModuleState::Running, true);
6131        let initial = ModuleSpec {
6132            module_id: "rescan-preserves-spawn-facts".to_string(),
6133            program: PathBuf::from("/spawned/module"),
6134            args: Vec::new(),
6135            env: Vec::new(),
6136            reserved: false,
6137            reserved_prefixes: Vec::new(),
6138            protocol: ModuleProtocol::Subc,
6139            overlap: Default::default(),
6140        };
6141        let module = supervisor.supervised_module(
6142            initial.clone(),
6143            supervisor.runtime_config(),
6144            snapshot,
6145            None,
6146        );
6147        let before = module.status().unwrap();
6148        let mut replacement = initial;
6149        replacement.program = PathBuf::from("/rescanned/replacement-module");
6150
6151        module
6152            .update_configuration(replacement, HealthConfig::default(), None)
6153            .await
6154            .unwrap();
6155
6156        let after = module.status().unwrap();
6157        assert_eq!(after.pid, before.pid);
6158        assert_eq!(after.spawned_at_ms, before.spawned_at_ms);
6159        assert_eq!(after.spawned_from, before.spawned_from);
6160        drop(module);
6161    }
6162}
6163
6164fn unix_ms_now() -> u64 {
6165    SystemTime::now()
6166        .duration_since(UNIX_EPOCH)
6167        .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
6168        .unwrap_or(0)
6169}
6170
6171async fn supervise_loop(
6172    mut spec: ModuleSpec,
6173    mut runtime: SupervisorRuntimeConfig,
6174    registry: Arc<Registry>,
6175    process_liveness: Arc<SupervisorProcessLiveness>,
6176    snapshot: SharedSnapshot,
6177    mut child: Option<SupervisedChild>,
6178    mut commands: mpsc::Receiver<SupervisorCommand>,
6179) {
6180    let mut health_probe = HealthProbeRuntime::default();
6181    // Registry writes (including embedded callers) notify this module only.
6182    // Subscribe before the first refresh; watch retains changes that arrive
6183    // while commands or probes are running. No polling fallback is needed.
6184    let mut registration_changes = match registry.subscribe_module_changes(&spec.module_id) {
6185        Ok(changes) => Some(changes),
6186        Err(err) => {
6187            // A poisoned registry cannot accept further writes, so it cannot
6188            // recover via a timer. Keep exit and command handling alive.
6189            warn!(module_id = %spec.module_id, error = %err, "health prober could not subscribe to registry");
6190            None
6191        }
6192    };
6193    // All restart backoffs run here, including health and operator requests.
6194    // While one is pending the loop serves commands, so disable or drain can
6195    // cancel the replacement without spawning a process just to stop it.
6196    let mut pending_respawn: Option<PendingRespawn> = None;
6197    // Commands a swap handed back to run next (see `swap::SwapEnd`). Served
6198    // before anything else so a stop that interrupted a swap runs at once.
6199    let mut requeued: VecDeque<SupervisorCommand> = VecDeque::new();
6200    #[cfg(target_os = "linux")]
6201    let mut previous_instance_recheck = tokio::time::interval_at(
6202        Instant::now() + Duration::from_secs(5),
6203        Duration::from_secs(5),
6204    );
6205    #[cfg(target_os = "linux")]
6206    previous_instance_recheck.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
6207    loop {
6208        #[cfg(test)]
6209        {
6210            lock_snapshot(&snapshot).unwrap().actor_select = None;
6211        }
6212        #[cfg(target_os = "macos")]
6213        if let Some(active) = child.as_mut() {
6214            active.confirm_privacy_exec().await;
6215        }
6216        if let Some(scheduled) = runtime
6217            .scheduled_respawn
6218            .lock()
6219            .unwrap_or_else(|p| p.into_inner())
6220            .take()
6221        {
6222            pending_respawn = Some(scheduled);
6223        }
6224        if pending_respawn.is_some() && (child.is_some() || !respawn_still_pending(&snapshot)) {
6225            pending_respawn = None;
6226            cancel_deferred_reload(
6227                &runtime,
6228                &spec.module_id,
6229                "respawn cancelled by a supervisor command",
6230            );
6231        }
6232        if child.is_none()
6233            && pending_respawn.is_none()
6234            && lock_snapshot(&snapshot).is_ok_and(|state| state.waiting_for_previous_pid.is_none())
6235        {
6236            cancel_deferred_reload(
6237                &runtime,
6238                &spec.module_id,
6239                "respawn cancelled before a replacement was spawned",
6240            );
6241            let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6242                state.respawn_pending = false;
6243                state.coalesced_restart_pending = false;
6244                if matches!(
6245                    state.state,
6246                    ModuleState::Restarting
6247                        | ModuleState::Starting
6248                        | ModuleState::Draining
6249                        | ModuleState::Unresponsive
6250                ) {
6251                    error!(module_id = %spec.module_id, state = ?state.state,
6252                        "supervision operation ended without a child or pending respawn; marking failed so start can retry");
6253                    state.state = ModuleState::Failed;
6254                    clear_current_process_facts(state);
6255                }
6256            });
6257        }
6258        if let Some(command) = requeued.pop_front() {
6259            if !handle_supervisor_command(
6260                command,
6261                &mut spec,
6262                &mut runtime,
6263                &registry,
6264                &process_liveness,
6265                &snapshot,
6266                &mut child,
6267                &mut commands,
6268                &mut requeued,
6269            )
6270            .await
6271            {
6272                return;
6273            }
6274            if child.is_some() || !respawn_still_pending(&snapshot) {
6275                pending_respawn = None;
6276                let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6277                    state.respawn_pending = false
6278                });
6279            }
6280            continue;
6281        }
6282        if child.is_some() {
6283            #[cfg(test)]
6284            {
6285                lock_snapshot(&snapshot).unwrap().actor_turns += 1;
6286            }
6287            health_probe.refresh_registration(&spec, &runtime, &registry, &snapshot);
6288            let wake_after = health_probe.wake_after();
6289            let probe_sleep = async move {
6290                match wake_after {
6291                    Some(delay) => sleep(delay).await,
6292                    None => std::future::pending().await,
6293                }
6294            };
6295            tokio::pin!(probe_sleep);
6296            // A `protocol: "none"` child (a plain process that never registers
6297            // over the subc wire, such as nats-server) with no HTTP health
6298            // check has nothing to probe, so it waits only for its exit or a
6299            // supervisor command. A rescan that adds an HTTP check is a command.
6300            let wire_child = running_protocol(&spec, &snapshot) == ModuleProtocol::Subc;
6301            #[cfg(test)]
6302            {
6303                let registration_pending = wire_child
6304                    && registration_changes
6305                        .as_ref()
6306                        .is_some_and(|changes| changes.has_changed().unwrap());
6307                let mut state = lock_snapshot(&snapshot).unwrap();
6308                state.actor_select = (!registration_pending).then_some(ActorSelectCheckpoint {
6309                    generation: state.spawn_generation,
6310                    turn: state.actor_turns,
6311                    registered_connection: health_probe.registered_connection,
6312                    next_probe_at: health_probe.next_probe_at,
6313                    wake_after,
6314                });
6315            }
6316            let active_child = child.as_mut().expect("child checked above");
6317            tokio::select! {
6318                wait_result = active_child.wait() => {
6319                    #[cfg(test)]
6320                    { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6321                    // Every arm below that gives up on the CHILD must keep the
6322                    // supervision task itself alive (child = None, loop
6323                    // continues into command-serving mode). Returning here
6324                    // closes the command channel, which makes the module
6325                    // permanently unrestartable in-band: a clean child exit
6326                    // of an enabled module once wedged the fleet this way
6327                    // ('supervisor command channel is closed') and required a
6328                    // full daemon restart to recover.
6329                    let exit_report = match wait_result {
6330                        Ok(status) => classify_reaped_child_exit(&snapshot, active_child, &status),
6331                        Err(err) => {
6332                            active_child.drain_stderr(&spec.module_id).await;
6333                            fail_after_wait_error(
6334                                &spec.module_id,
6335                                &snapshot,
6336                                &runtime.terminal_ring,
6337                                &runtime.spawn_events,
6338                                format!("failed to wait for supervised module: {err}"),
6339                            );
6340                            untrack_if_registration_released(
6341                                &process_liveness,
6342                                &registry,
6343                                &spec.module_id,
6344                                &snapshot,
6345                            );
6346                            error!(module_id = %spec.module_id, error = %err, "failed to wait for supervised module");
6347                            child = None;
6348                            continue;
6349                        }
6350                    };
6351                    active_child.drain_stderr(&spec.module_id).await;
6352
6353                    let next = on_child_exit(
6354                        &spec,
6355                        runtime.restart_policy,
6356                        &registry,
6357                        &snapshot,
6358                        &runtime.terminal_ring,
6359                        &runtime.spawn_events,
6360                        &runtime.child_roster,
6361                        exit_report,
6362                    ).await;
6363                    runtime.start_requester = None;
6364                    runtime.pending_activity_restart = false;
6365                    // The exit is recorded, so a daemon shutdown may stop
6366                    // waiting for this child (see `SupervisedChild::wait`).
6367                    active_child.release_roster();
6368                    match next {
6369                        NextAction::Stop { registration_released } => {
6370                            if registration_released {
6371                                process_liveness.untrack_if_current(&spec.module_id, &snapshot);
6372                            }
6373                            child = None;
6374                        }
6375                        NextAction::Restart { schedule } => {
6376                            let delay = schedule.map_or(
6377                                runtime.restart_policy.delay_for_restart(0),
6378                                |schedule| schedule.delay,
6379                            );
6380                            if let Some(schedule) = schedule {
6381                                log_crash_respawn(&spec.module_id, schedule);
6382                            }
6383                            // The exited child is fully recorded at this point,
6384                            // so release it and count the backoff down in the
6385                            // command-serving branch below rather than sleeping
6386                            // here: commands cannot be received from inside this
6387                            // select arm, and an operator disable or drain that
6388                            // arrives during the backoff must cancel the pending
6389                            // respawn instead of waiting for it to spawn first.
6390                            child = None;
6391                            pending_respawn = Some(PendingRespawn { deadline: Instant::now() + delay, kind: RespawnKind::Spawn });
6392                             let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| state.respawn_pending = true);
6393                        }
6394                    }
6395                }
6396                command = commands.recv() => {
6397                    #[cfg(test)]
6398                    { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6399                    let Some(command) = command else {
6400                        return;
6401                    };
6402                    if !handle_supervisor_command(
6403                        command,
6404                        &mut spec,
6405                        &mut runtime,
6406                        &registry,
6407                        &process_liveness,
6408                        &snapshot,
6409                        &mut child,
6410                        &mut commands,
6411                        &mut requeued,
6412                    ).await {
6413                        return;
6414                    }
6415                }
6416                _ = async {
6417                    match registration_changes.as_mut() {
6418                        Some(changes) => { let _ = changes.changed().await; }
6419                        None => std::future::pending().await,
6420                    }
6421                }, if wire_child => {
6422                    #[cfg(test)]
6423                    { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6424                }
6425                _ = &mut probe_sleep => {
6426                    #[cfg(test)]
6427                    { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6428                    if health_probe.due() {
6429                        run_health_probe_cycle(
6430                            &spec,
6431                            &runtime,
6432                            &registry,
6433                            &process_liveness,
6434                            &snapshot,
6435                            &mut child,
6436                        ).await;
6437                        if child.is_none() {
6438                            runtime.start_requester = None;
6439                            runtime.pending_activity_restart = false;
6440                        }
6441                        if child.is_some() {
6442                            health_probe.schedule_next(&spec, runtime.health.cadence);
6443                        }
6444                    }
6445                }
6446            }
6447        } else if let Some(pending) = pending_respawn {
6448            tokio::select! {
6449                _ = sleep_until(pending.deadline) => {
6450                    pending_respawn = None;
6451                let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| state.respawn_pending = false);
6452                    // A command handled below while the backoff elapsed may
6453                    // have stopped the module; never respawn past an operator's
6454                    // disable or drain.
6455                    if !respawn_still_pending(&snapshot) {
6456                        continue;
6457                    }
6458                    // The daemon began shutting down during the backoff: the
6459                    // spawn would be refused anyway, and refusing it here
6460                    // leaves the module stopped instead of reporting a
6461                    // failed restart.
6462                    if runtime.child_roster.is_closed() {
6463                        let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6464                            state.state = ModuleState::Stopped;
6465                        });
6466                        debug!(module_id = %spec.module_id, "crash respawn cancelled by daemon shutdown");
6467                        continue;
6468                    }
6469                    if let Err(err) = release_dead_registration(
6470                        &registry,
6471                        runtime.forwarding.as_deref(),
6472                        &snapshot,
6473                        &spec.module_id,
6474                    ).await {
6475                        fail_snapshot(&snapshot, Some(&spec.module_id), None);
6476                        error!(module_id = %spec.module_id, error = %err, "registration did not release before restart");
6477                        continue;
6478                    }
6479
6480                    if matches!(pending.kind, RespawnKind::Reload) {
6481                        let reply = runtime.deferred_reload_reply.lock().unwrap_or_else(|p| p.into_inner()).take();
6482                        let result = finish_reload_child(&spec, &runtime, &registry, &process_liveness, &snapshot, &mut child).await;
6483                        if result.is_ok() && runtime.pending_activity_restart {
6484                            runtime.record_activity_transition("subc.module_restarted", &spec.module_id, runtime.start_requester.clone());
6485                        }
6486                        runtime.pending_activity_restart = false;
6487                        if let Some(reply) = reply { let _ = reply.send(result); }
6488                        continue;
6489                    }
6490                    process_liveness.track(spec.module_id.clone(), Arc::clone(&snapshot));
6491                    match spawn_and_mark_running(&spec, &runtime, &snapshot) {
6492                        Ok(next_child) => {
6493                            child = Some(next_child);
6494                            if runtime.pending_activity_restart {
6495                                runtime.record_activity_transition("subc.module_restarted", &spec.module_id, runtime.start_requester.clone());
6496                                runtime.pending_activity_restart = false;
6497                            }
6498                            debug!(module_id = %spec.module_id, "supervised module restarted after crash");
6499                        }
6500                        Err(err) => {
6501                            fail_snapshot(&snapshot, Some(&spec.module_id), None);
6502                            process_liveness.untrack_if_current(&spec.module_id, &snapshot);
6503                            error!(module_id = %spec.module_id, error = %err, "failed to restart supervised module");
6504                        }
6505                    }
6506                }
6507                command = commands.recv() => {
6508                    let Some(command) = command else {
6509                        return;
6510                    };
6511                    if !handle_supervisor_command(
6512                        command,
6513                        &mut spec,
6514                        &mut runtime,
6515                        &registry,
6516                        &process_liveness,
6517                        &snapshot,
6518                        &mut child,
6519                        &mut commands,
6520                        &mut requeued,
6521                    ).await {
6522                        return;
6523                    }
6524                    // Reconcile the pending respawn with what the command did:
6525                    // a start may already have spawned a fresh child,
6526                    // while a disable or drain moved the snapshot out of the
6527                    // state the respawn was counting down from.
6528                    if child.is_some() || !respawn_still_pending(&snapshot) {
6529                        pending_respawn = None;
6530                let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| state.respawn_pending = false);
6531                    }
6532                }
6533            }
6534        } else {
6535            #[cfg(target_os = "linux")]
6536            let command = if lock_snapshot(&snapshot)
6537                .is_ok_and(|state| state.waiting_for_previous_pid.is_some())
6538            {
6539                tokio::select! {
6540                    command = commands.recv() => command,
6541                    _ = previous_instance_recheck.tick() => {
6542                        if runtime.child_roster.previous_instance_pid(&spec.module_id).is_none() {
6543                            let enabled = lock_snapshot(&snapshot).is_ok_and(|state| state.enabled);
6544                            if enabled && !runtime.child_roster.is_closed() {
6545                                process_liveness.track(spec.module_id.clone(), Arc::clone(&snapshot));
6546                                match spawn_and_mark_running(&spec, &runtime, &snapshot) {
6547                                    Ok(next_child) => child = Some(next_child),
6548                                    Err(error) => {
6549                                        fail_snapshot(&snapshot, Some(&spec.module_id), None);
6550                                        error!(module_id = %spec.module_id, %error, "failed to start module after its previous instance exited");
6551                                    }
6552                                }
6553                            } else {
6554                                let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6555                                    state.waiting_for_previous_pid = None;
6556                                });
6557                            }
6558                        }
6559                        continue;
6560                    }
6561                }
6562            } else {
6563                commands.recv().await
6564            };
6565            #[cfg(not(target_os = "linux"))]
6566            let command = commands.recv().await;
6567            let Some(command) = command else {
6568                return;
6569            };
6570            if !handle_supervisor_command(
6571                command,
6572                &mut spec,
6573                &mut runtime,
6574                &registry,
6575                &process_liveness,
6576                &snapshot,
6577                &mut child,
6578                &mut commands,
6579                &mut requeued,
6580            )
6581            .await
6582            {
6583                return;
6584            }
6585        }
6586    }
6587}
6588
6589fn log_crash_respawn(module_id: &str, schedule: CrashRestartSchedule) {
6590    info!(
6591        module_id,
6592        restart_in_window = schedule.restart_in_window,
6593        delay_ms = schedule.delay.as_millis() as u64,
6594        "respawning after crash"
6595    );
6596}
6597
6598/// Whether the respawn a backoff was counting down to is still wanted. A
6599/// disable or drain handled while the backoff elapsed moves the snapshot out
6600/// of `Restarting`, and the operator's stop must win over the pending respawn,
6601/// so every sleep-then-spawn path re-validates against the live snapshot
6602/// instead of assuming the state it left behind still holds.
6603fn respawn_still_pending(snapshot: &SharedSnapshot) -> bool {
6604    matches!(
6605        lock_snapshot(snapshot),
6606        Ok(state) if state.enabled && state.state == ModuleState::Restarting
6607    )
6608}
6609
6610enum NextAction {
6611    Stop {
6612        registration_released: bool,
6613    },
6614    Restart {
6615        schedule: Option<CrashRestartSchedule>,
6616    },
6617}
6618
6619#[allow(clippy::too_many_arguments)]
6620async fn handle_supervisor_command(
6621    command: SupervisorCommand,
6622    spec: &mut ModuleSpec,
6623    runtime: &mut SupervisorRuntimeConfig,
6624    registry: &Arc<Registry>,
6625    process_liveness: &SupervisorProcessLiveness,
6626    snapshot: &SharedSnapshot,
6627    child: &mut Option<SupervisedChild>,
6628    commands: &mut mpsc::Receiver<SupervisorCommand>,
6629    requeued: &mut VecDeque<SupervisorCommand>,
6630) -> bool {
6631    #[cfg(target_os = "linux")]
6632    let command = if let Some(pid) = runtime.child_roster.previous_instance_pid(&spec.module_id) {
6633        match command {
6634            SupervisorCommand::Restart { reply, .. }
6635            | SupervisorCommand::Reload { reply, .. }
6636            | SupervisorCommand::Swap { reply, .. } => {
6637                let _ = reply.send(Err(previous_instance_error(spec, pid)));
6638                return true;
6639            }
6640            SupervisorCommand::SetEnabled {
6641                enabled: true,
6642                reply,
6643                ..
6644            } => {
6645                let _ = reply.send(Err(previous_instance_error(spec, pid)));
6646                return true;
6647            }
6648            other => other,
6649        }
6650    } else {
6651        command
6652    };
6653    match command {
6654        SupervisorCommand::Drain { requester, reply } => {
6655            // A plain stop runs no forwarding drain, so nothing reaches the
6656            // module over its connection before the wait: ask by signal.
6657            let result = drain_optional_child(
6658                &spec.module_id,
6659                spec.protocol,
6660                StopNotice::NotSent,
6661                registry,
6662                runtime.forwarding.as_deref(),
6663                snapshot,
6664                &runtime.terminal_ring,
6665                &runtime.spawn_events,
6666                child,
6667                runtime.drain_timeout,
6668                ModuleState::Stopped,
6669                None,
6670            )
6671            .await;
6672            let registration_released = result.is_ok();
6673            if registration_released {
6674                runtime.record_activity_transition(
6675                    "subc.module_stopped",
6676                    &spec.module_id,
6677                    requester,
6678                );
6679            }
6680            let _ = reply.send(result);
6681            if registration_released {
6682                process_liveness.untrack_if_current(&spec.module_id, snapshot);
6683            }
6684            false
6685        }
6686        SupervisorCommand::Retire { requester, reply } => {
6687            let result = async {
6688                let stop_notice = begin_forwarding_drain_if_configured(
6689                    spec,
6690                    runtime,
6691                    registry,
6692                    snapshot,
6693                    None,
6694                    RouteCloseReason::Disable,
6695                )
6696                .await?;
6697                drain_optional_child(
6698                    &spec.module_id,
6699                    spec.protocol,
6700                    stop_notice,
6701                    registry,
6702                    runtime.forwarding.as_deref(),
6703                    snapshot,
6704                    &runtime.terminal_ring,
6705                    &runtime.spawn_events,
6706                    child,
6707                    runtime.drain_timeout,
6708                    ModuleState::Stopped,
6709                    None,
6710                )
6711                .await
6712            }
6713            .await;
6714            let registration_released = result.is_ok();
6715            if registration_released {
6716                runtime.record_activity_transition(
6717                    "subc.module_stopped",
6718                    &spec.module_id,
6719                    requester,
6720                );
6721            }
6722            let _ = reply.send(result);
6723            if registration_released {
6724                process_liveness.untrack_if_current(&spec.module_id, snapshot);
6725            }
6726            false
6727        }
6728        SupervisorCommand::Restart {
6729            requester,
6730            drain_timeout_ms,
6731            received_at_generation,
6732            queued_at,
6733            reply,
6734        } => {
6735            // Without this line a restart that waited in the queue (behind a
6736            // health probe cycle or another command) was invisible: the log
6737            // showed only the drain timing out, minutes after the operator's call.
6738            info!(
6739                module_id = %spec.module_id,
6740                queued_ms = u64::try_from(queued_at.elapsed().as_millis()).unwrap_or(u64::MAX),
6741                "restart command dequeued"
6742            );
6743            // ACK AT INITIATION, not completion. The blocking form deadlocked any
6744            // caller whose own request lane rides the module being restarted: the
6745            // caller's in-flight request keeps the drain from quiescing, the drain
6746            // keeps the restart from completing, and the completion keeps the reply
6747            // from releasing the caller — so the drain always timed out and cut the
6748            // initiator with a GOODBYE, even on a healthy module. Replying once the
6749            // restart is validated lets a self-lane caller settle, which is exactly
6750            // what makes the drain succeed. Completion is observable via
6751            // supervisor.list / module status; a post-ack failure lands the module
6752            // in a visible terminal state below rather than in a reply nobody can
6753            // receive.
6754            let validation = match lock_snapshot(snapshot) {
6755                Ok(state) if !state.enabled => Err(SuperviseError::Disabled {
6756                    module_id: spec.module_id.clone(),
6757                }),
6758                Ok(_) => Ok(()),
6759                Err(err) => Err(err),
6760            };
6761            let initiated = validation.is_ok();
6762            let _ = reply.send(validation);
6763            // A restart asks for a fresh process. Commands run one at a time,
6764            // so a restart queued behind another restart (two operator calls
6765            // in quick succession) is dequeued the moment the first one has
6766            // spawned its replacement -- before that process has sent HELLO.
6767            // Running it would drain and kill the process the first restart
6768            // just produced, which is the opposite of what both callers asked
6769            // for. If a process spawned after this request was received is
6770            // still supervised, the request is already satisfied. Not when the
6771            // configuration changed since that spawn: then the newer process
6772            // predates the spec this restart may exist to apply.
6773            let satisfied_by_generation = if initiated && child.is_some() {
6774                lock_snapshot(snapshot).ok().and_then(|state| {
6775                    (state.spawn_generation > received_at_generation
6776                        && !state.configuration_updated_since_spawn)
6777                        .then_some(state.spawn_generation)
6778                })
6779            } else {
6780                None
6781            };
6782            let satisfied_by_pending = initiated
6783                && child.is_none()
6784                && lock_snapshot(snapshot).ok().is_some_and(|mut state| {
6785                    let pending = state.respawn_pending && !state.configuration_updated_since_spawn;
6786                    if pending {
6787                        state.coalesced_restart_pending = true;
6788                    }
6789                    pending
6790                });
6791            if satisfied_by_pending {
6792                debug!(module_id = %spec.module_id, "restart coalesced into the pending replacement");
6793            } else if let Some(generation) = satisfied_by_generation {
6794                info!(
6795                    module_id = %spec.module_id,
6796                    received_at_generation,
6797                    "restart already satisfied by generation {generation}; not restarting again"
6798                );
6799            } else if initiated {
6800                // Precedence: this restart's operator override, else the module's
6801                // configured budget (already resolved into the runtime).
6802                let drain_timeout = drain_timeout_ms
6803                    .map(Duration::from_millis)
6804                    .unwrap_or(runtime.drain_timeout);
6805                if let Err(err) = restart_child(
6806                    spec,
6807                    runtime,
6808                    registry,
6809                    process_liveness,
6810                    snapshot,
6811                    child,
6812                    drain_timeout,
6813                )
6814                .await
6815                {
6816                    warn!(
6817                        module_id = %spec.module_id,
6818                        error = %err,
6819                        "operator restart failed after initiation ack; module state carries the outcome"
6820                    );
6821                    let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
6822                        state.state = ModuleState::Failed;
6823                        clear_current_process_facts(state);
6824                    });
6825                } else {
6826                    runtime.start_requester = requester;
6827                    runtime.pending_activity_restart = true;
6828                }
6829            }
6830            true
6831        }
6832        SupervisorCommand::Reload { requester, reply } => {
6833            let result =
6834                reload_child(spec, runtime, registry, process_liveness, snapshot, child).await;
6835            if result.is_ok() {
6836                runtime.start_requester = requester;
6837                runtime.pending_activity_restart = true;
6838            }
6839            if result.is_ok()
6840                && runtime
6841                    .scheduled_respawn
6842                    .lock()
6843                    .unwrap_or_else(|p| p.into_inner())
6844                    .as_ref()
6845                    .is_some_and(|pending| matches!(pending.kind, RespawnKind::Reload))
6846            {
6847                *runtime
6848                    .deferred_reload_reply
6849                    .lock()
6850                    .unwrap_or_else(|p| p.into_inner()) = Some(reply);
6851            } else {
6852                let _ = reply.send(result);
6853            }
6854            true
6855        }
6856        SupervisorCommand::SetEnabled {
6857            enabled,
6858            requester,
6859            reply,
6860        } => {
6861            let previous_requester = runtime.start_requester.clone();
6862            if enabled {
6863                runtime.start_requester = requester.clone();
6864            }
6865            let result = set_child_enabled(
6866                spec,
6867                runtime,
6868                registry,
6869                process_liveness,
6870                snapshot,
6871                child,
6872                enabled,
6873            )
6874            .await;
6875            if matches!(result, Ok(true)) {
6876                runtime.pending_activity_restart = false;
6877                if enabled {
6878                    runtime.start_requester = requester;
6879                } else {
6880                    runtime.start_requester = None;
6881                    runtime.record_activity_transition(
6882                        "subc.module_disabled",
6883                        &spec.module_id,
6884                        requester,
6885                    );
6886                }
6887            } else {
6888                runtime.start_requester = previous_requester;
6889            }
6890            let _ = reply.send(result);
6891            true
6892        }
6893        SupervisorCommand::UpdateConfiguration {
6894            spec: next_spec,
6895            health,
6896            drain_timeout_ms,
6897            reply,
6898        } => {
6899            if let Some(handle) = &runtime.supervisor_handle {
6900                handle.apply_identity_configuration(&next_spec);
6901            }
6902            *spec = next_spec;
6903            let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
6904                state.configuration_updated_since_spawn = true;
6905            });
6906            let health_changed = runtime.health != health;
6907            runtime.health = health;
6908            // Reset the cadence and old endpoint's failure streak on a live
6909            // health-policy change rather than waiting for its old deadline.
6910            if health_changed {
6911                let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
6912                    state.health = ModuleHealthStatus::default();
6913                });
6914            }
6915            runtime.drain_timeout = drain_timeout_ms
6916                .map(Duration::from_millis)
6917                .unwrap_or(runtime.default_drain_timeout);
6918            *runtime
6919                .effective_drain_timeout
6920                .lock()
6921                .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.drain_timeout;
6922            let _ = reply.send(());
6923            true
6924        }
6925        SupervisorCommand::Swap {
6926            requester,
6927            ready_timeout,
6928            reply,
6929        } => {
6930            let mut swap_runtime = runtime.clone();
6931            swap_runtime.start_requester = requester;
6932            let before = lock_snapshot(snapshot)
6933                .ok()
6934                .map(|state| state.spawn_generation);
6935            let end = swap::run_swap(
6936                spec,
6937                &swap_runtime,
6938                registry,
6939                process_liveness,
6940                snapshot,
6941                child,
6942                commands,
6943                ready_timeout.unwrap_or(DEFAULT_SWAP_READY_TIMEOUT),
6944                reply,
6945            )
6946            .await;
6947            if before
6948                != lock_snapshot(snapshot)
6949                    .ok()
6950                    .map(|state| state.spawn_generation)
6951            {
6952                runtime.start_requester = swap_runtime.start_requester;
6953            }
6954            requeued.extend(end.requeue);
6955            true
6956        }
6957    }
6958}
6959
6960async fn restart_child(
6961    spec: &ModuleSpec,
6962    runtime: &SupervisorRuntimeConfig,
6963    registry: &Registry,
6964    process_liveness: &SupervisorProcessLiveness,
6965    snapshot: &SharedSnapshot,
6966    child: &mut Option<SupervisedChild>,
6967    drain_timeout: Duration,
6968) -> Result<(), SuperviseError> {
6969    // Restart cycles a running module; it must not silently start a disabled one.
6970    if !lock_snapshot(snapshot)?.enabled {
6971        return Err(SuperviseError::Disabled {
6972            module_id: spec.module_id.clone(),
6973        });
6974    }
6975    let stop_notice = begin_forwarding_drain_with_timeout(
6976        spec,
6977        runtime,
6978        registry,
6979        snapshot,
6980        None,
6981        RouteCloseReason::Restart,
6982        drain_timeout,
6983    )
6984    .await?;
6985
6986    if child.is_some() {
6987        drain_optional_child(
6988            &spec.module_id,
6989            spec.protocol,
6990            stop_notice,
6991            registry,
6992            runtime.forwarding.as_deref(),
6993            snapshot,
6994            &runtime.terminal_ring,
6995            &runtime.spawn_events,
6996            child,
6997            drain_timeout,
6998            ModuleState::Restarting,
6999            Some(true),
7000        )
7001        .await?;
7002    } else {
7003        update_snapshot(snapshot, Some(&spec.module_id), |state| {
7004            state.enabled = true;
7005            state.state = ModuleState::Restarting;
7006            clear_current_process_facts(state);
7007        })?;
7008        release_dead_registration(
7009            registry,
7010            runtime.forwarding.as_deref(),
7011            snapshot,
7012            &spec.module_id,
7013        )
7014        .await?;
7015    }
7016
7017    reset_restart_count(snapshot, &spec.module_id)?;
7018    process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
7019    schedule_respawn(
7020        runtime,
7021        snapshot,
7022        &spec.module_id,
7023        runtime.restart_policy.backoff,
7024        RespawnKind::Spawn,
7025    )
7026}
7027
7028async fn reload_child(
7029    spec: &ModuleSpec,
7030    runtime: &SupervisorRuntimeConfig,
7031    registry: &Registry,
7032    process_liveness: &SupervisorProcessLiveness,
7033    snapshot: &SharedSnapshot,
7034    child: &mut Option<SupervisedChild>,
7035) -> Result<(), SuperviseError> {
7036    // Reload cycles a running module; it must not silently start a disabled one.
7037    if !lock_snapshot(snapshot)?.enabled {
7038        return Err(SuperviseError::Disabled {
7039            module_id: spec.module_id.clone(),
7040        });
7041    }
7042    let stop_notice = begin_forwarding_drain(
7043        spec,
7044        runtime,
7045        registry,
7046        snapshot,
7047        Some(true),
7048        RouteCloseReason::Reload,
7049    )
7050    .await?;
7051
7052    if child.is_some() {
7053        drain_optional_child(
7054            &spec.module_id,
7055            spec.protocol,
7056            stop_notice,
7057            registry,
7058            runtime.forwarding.as_deref(),
7059            snapshot,
7060            &runtime.terminal_ring,
7061            &runtime.spawn_events,
7062            child,
7063            runtime.drain_timeout,
7064            ModuleState::Restarting,
7065            Some(true),
7066        )
7067        .await?;
7068    } else {
7069        update_snapshot(snapshot, Some(&spec.module_id), |state| {
7070            state.enabled = true;
7071            state.state = ModuleState::Restarting;
7072            clear_current_process_facts(state);
7073        })?;
7074        release_dead_registration(
7075            registry,
7076            runtime.forwarding.as_deref(),
7077            snapshot,
7078            &spec.module_id,
7079        )
7080        .await?;
7081    }
7082
7083    reset_restart_count(snapshot, &spec.module_id)?;
7084    process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
7085    schedule_respawn(
7086        runtime,
7087        snapshot,
7088        &spec.module_id,
7089        runtime.restart_policy.backoff,
7090        RespawnKind::Reload,
7091    )
7092}
7093
7094async fn finish_reload_child(
7095    spec: &ModuleSpec,
7096    runtime: &SupervisorRuntimeConfig,
7097    registry: &Registry,
7098    process_liveness: &SupervisorProcessLiveness,
7099    snapshot: &SharedSnapshot,
7100    child: &mut Option<SupervisedChild>,
7101) -> Result<(), SuperviseError> {
7102    process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
7103    let next_child = match spawn_and_mark_running(spec, runtime, snapshot) {
7104        Ok(next_child) => next_child,
7105        Err(err) => {
7106            return handle_reload_spawn_failure(
7107                spec,
7108                runtime,
7109                process_liveness,
7110                snapshot,
7111                child,
7112                format!("new child failed to spawn: {err}"),
7113            )
7114            .await;
7115        }
7116    };
7117    *child = Some(next_child);
7118
7119    let wait_outcome = {
7120        let active_child = child.as_mut().expect("new reload child was just stored");
7121        wait_for_registration_after_reload(
7122            registry,
7123            &spec.module_id,
7124            snapshot,
7125            active_child,
7126            REGISTRY_RELEASE_TIMEOUT,
7127        )
7128        .await?
7129    };
7130
7131    match wait_outcome {
7132        RegistrationWaitOutcome::Registered => {
7133            debug!(module_id = %spec.module_id, "supervised module reloaded and registered");
7134            Ok(())
7135        }
7136        RegistrationWaitOutcome::Exited(exit_report) => {
7137            if let Some(active_child) = child.as_mut() {
7138                active_child.drain_stderr(&spec.module_id).await;
7139            }
7140            // Keep the reaped child's roster guard until its terminal is written.
7141            // Shutdown waits on that guard, not on the child Option used for respawn.
7142            let mut exited_child = child.take().expect("exited reload child is still stored");
7143            #[cfg(test)]
7144            if let Some(gate) = &runtime.test_reload_exit_record_gate {
7145                gate.reached.notify_one();
7146                gate.resume.notified().await;
7147            }
7148            let result = handle_reload_child_registration_failure(
7149                spec,
7150                runtime,
7151                registry,
7152                process_liveness,
7153                snapshot,
7154                child,
7155                ReloadRegistrationFailure {
7156                    exit_report: registration_failure_exit_report(exit_report),
7157                    reason: exited_child
7158                        .spawn_failure
7159                        .clone()
7160                        .unwrap_or_else(|| "new child exited before registering".to_string()),
7161                },
7162            )
7163            .await;
7164            exited_child.release_roster();
7165            result
7166        }
7167        RegistrationWaitOutcome::TimedOut => {
7168            let mut timed_out_child = child
7169                .take()
7170                .expect("timed-out reload child is still running");
7171            timed_out_child
7172                .start_kill()
7173                .map_err(|source| SuperviseError::Kill {
7174                    module_id: spec.module_id.clone(),
7175                    source,
7176                })?;
7177            let status = timed_out_child
7178                .wait()
7179                .await
7180                .map_err(|source| SuperviseError::Wait {
7181                    module_id: spec.module_id.clone(),
7182                    source,
7183                })?;
7184            timed_out_child.drain_stderr(&spec.module_id).await;
7185            handle_reload_child_registration_failure(
7186                spec,
7187                runtime,
7188                registry,
7189                process_liveness,
7190                snapshot,
7191                child,
7192                ReloadRegistrationFailure {
7193                    exit_report: registration_failure_exit_report(classify_reaped_child_exit(
7194                        snapshot,
7195                        &timed_out_child,
7196                        &status,
7197                    )),
7198                    reason: format!(
7199                        "new child did not register within {:?}",
7200                        REGISTRY_RELEASE_TIMEOUT
7201                    ),
7202                },
7203            )
7204            .await
7205        }
7206    }
7207}
7208
7209async fn set_child_enabled(
7210    spec: &ModuleSpec,
7211    runtime: &SupervisorRuntimeConfig,
7212    registry: &Registry,
7213    process_liveness: &SupervisorProcessLiveness,
7214    snapshot: &SharedSnapshot,
7215    child: &mut Option<SupervisedChild>,
7216    enabled: bool,
7217) -> Result<bool, SuperviseError> {
7218    let (current_enabled, current_state, respawn_pending) = {
7219        let state = lock_snapshot(snapshot)?;
7220        (state.enabled, state.state, state.respawn_pending)
7221    };
7222    // `start` (enable on an already-enabled module) heals TERMINAL states instead
7223    // of no-op'ing: a module whose restart budget exhausted (Failed) or that exited
7224    // clean (Stopped) has no live process and no other in-band recovery — the
7225    // operator's start is the explicit recovery act and resets the budget. Without
7226    // this arm the only revival was subc-probe --supervisor-restart in a terminal,
7227    // which the 2026-07-14 aft outage proved is a trap when the failed module is
7228    // the one providing every agent's shell.
7229    let revive_terminal = enabled
7230        && current_enabled
7231        && child.is_none()
7232        && (matches!(current_state, ModuleState::Failed | ModuleState::Stopped)
7233            || (current_state == ModuleState::Restarting && !respawn_pending));
7234    if current_enabled == enabled && !revive_terminal {
7235        return Ok(false);
7236    }
7237
7238    if enabled {
7239        update_snapshot(snapshot, Some(&spec.module_id), |state| {
7240            state.enabled = true;
7241            state.state = ModuleState::Starting;
7242            clear_current_process_facts(state);
7243        })?;
7244        #[cfg(test)]
7245        if runtime.test_seed_stale_facts_before_enable_spawn {
7246            update_snapshot(snapshot, Some(&spec.module_id), |state| {
7247                state.process_alive = true;
7248                state.pid = Some(41);
7249                state.spawned_at_ms = Some(42);
7250                state.spawned_from = Some(PathBuf::from("/spawned/module"));
7251                state.spawned_file_identity = Some(SpawnedFileIdentity {
7252                    device: 43,
7253                    inode: 44,
7254                    #[cfg(windows)]
7255                    windows_image: None,
7256                });
7257            })?;
7258        }
7259        release_dead_registration(
7260            registry,
7261            runtime.forwarding.as_deref(),
7262            snapshot,
7263            &spec.module_id,
7264        )
7265        .await?;
7266        reset_restart_count(snapshot, &spec.module_id)?;
7267        process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
7268        let next_child = match spawn_and_mark_running(spec, runtime, snapshot) {
7269            Ok(next_child) => next_child,
7270            Err(err) => {
7271                if let Err(state_err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
7272                    state.state = ModuleState::Failed;
7273                    clear_current_process_facts(state);
7274                }) {
7275                    error!(module_id = %spec.module_id, error = %state_err, "failed to record enable spawn failure");
7276                }
7277                process_liveness.untrack_if_current(&spec.module_id, snapshot);
7278                return Err(err);
7279            }
7280        };
7281        *child = Some(next_child);
7282        debug!(module_id = %spec.module_id, "supervised module enabled");
7283        Ok(true)
7284    } else {
7285        let stop_notice = begin_forwarding_drain_if_configured(
7286            spec,
7287            runtime,
7288            registry,
7289            snapshot,
7290            Some(false),
7291            RouteCloseReason::Disable,
7292        )
7293        .await?;
7294        drain_optional_child(
7295            &spec.module_id,
7296            spec.protocol,
7297            stop_notice,
7298            registry,
7299            runtime.forwarding.as_deref(),
7300            snapshot,
7301            &runtime.terminal_ring,
7302            &runtime.spawn_events,
7303            child,
7304            runtime.drain_timeout,
7305            ModuleState::Disabled,
7306            Some(false),
7307        )
7308        .await?;
7309        debug!(module_id = %spec.module_id, "supervised module disabled");
7310        Ok(true)
7311    }
7312}
7313
7314#[allow(clippy::too_many_arguments)]
7315async fn on_child_exit(
7316    spec: &ModuleSpec,
7317    policy: RestartPolicy,
7318    registry: &Registry,
7319    snapshot: &SharedSnapshot,
7320    terminal_ring: &Arc<Mutex<TerminalRing>>,
7321    spawn_events: &SpawnEventFeed,
7322    roster: &ChildRoster,
7323    exit_report: ExitReport,
7324) -> NextAction {
7325    // Once the daemon has begun shutting down, no exit is a crash to recover
7326    // from: the module is exiting because the daemon is going away (EOF on its
7327    // connection, or a service manager signalling the whole cgroup). Record it
7328    // as such and never schedule a respawn, which would only start a process
7329    // for the shutdown to end again.
7330    if roster.is_closed() {
7331        return on_child_exit_during_daemon_shutdown(
7332            spec,
7333            registry,
7334            snapshot,
7335            terminal_ring,
7336            spawn_events,
7337            exit_report,
7338        )
7339        .await;
7340    }
7341    // Every stop the supervisor itself asks for (operator stop, disable,
7342    // restart, reload, swap, a health restart, a drain that runs out of budget)
7343    // takes the child out of the supervise loop and reaps it in
7344    // `drain_child_to_state`, and daemon shutdown is handled above. So an exit
7345    // that reaches this point was not requested by the daemon.
7346    //
7347    // For a subc-wire module a clean exit is still a stop: those modules are
7348    // written to re-raise SIGTERM, so a stray outside signal already reads as a
7349    // crash, and exiting 0 is a deliberate choice the module made. A
7350    // `protocol: "none"` module is a stock program we cannot change, and many
7351    // of them (nats-server among them) exit 0 on SIGTERM. Treating that as a
7352    // stop would leave the module down for good after any stray signal, so it
7353    // goes through the crash path instead: it spends restart budget, respawns
7354    // with the crash backoff, and ends `failed` when the budget runs out.
7355    let unrequested_clean_exit_of_protocol_none = exit_report.kind == ExitKind::Clean
7356        && running_protocol(spec, snapshot) == ModuleProtocol::None;
7357    match exit_report.kind {
7358        ExitKind::Clean if !unrequested_clean_exit_of_protocol_none => {
7359            info!(
7360                module_id = %spec.module_id,
7361                exit_code = ?exit_report.code,
7362                exit_signal = ?exit_report.signal,
7363                "supervised module exited cleanly"
7364            );
7365            if let Err(err) = publish_after_terminal_record(
7366                &spec.module_id,
7367                || {
7368                    record_terminal(
7369                        &spec.module_id,
7370                        terminal_ring,
7371                        spawn_events,
7372                        &exit_report,
7373                        TerminalDisposition::Stopped,
7374                    )
7375                },
7376                || {
7377                    update_snapshot(snapshot, Some(&spec.module_id), |state| {
7378                        state.state = ModuleState::Stopped;
7379                        clear_current_process_facts(state);
7380                        state.last_exit = Some(exit_report.clone());
7381                    })
7382                },
7383            ) {
7384                error!(module_id = %spec.module_id, error = %err, "failed to publish clean module exit");
7385            }
7386            let registration_released = match wait_for_registration_release(
7387                registry,
7388                &spec.module_id,
7389                REGISTRY_RELEASE_TIMEOUT,
7390            )
7391            .await
7392            {
7393                Ok(()) => true,
7394                Err(err) => {
7395                    warn!(module_id = %spec.module_id, error = %err, "registration still active after clean exit");
7396                    false
7397                }
7398            };
7399            NextAction::Stop {
7400                registration_released,
7401            }
7402        }
7403        ExitKind::Clean | ExitKind::Crash => {
7404            if unrequested_clean_exit_of_protocol_none {
7405                warn!(
7406                    module_id = %spec.module_id,
7407                    exit_code = ?exit_report.code,
7408                    exit_signal = ?exit_report.signal,
7409                    "protocol-none module exited cleanly without a stop request; handling it as a crash"
7410                );
7411            } else {
7412                warn!(
7413                    module_id = %spec.module_id,
7414                    exit_code = ?exit_report.code,
7415                    exit_signal = ?exit_report.signal,
7416                    "supervised module exited abnormally (crash)"
7417                );
7418            }
7419            let mut restart_schedule = None;
7420            let mut disposition = TerminalDisposition::Disabled;
7421            let mut final_state = ModuleState::Disabled;
7422            // Set only when the budget is what stopped the module, so the
7423            // terminal record says which limit was hit rather than leaving
7424            // `failed` to be read as "crashed once, badly".
7425            let mut disposition_detail = lock_snapshot(snapshot)
7426                .ok()
7427                .and_then(|mut state| state.spawn_failure.take());
7428            let now = Instant::now();
7429            if let Err(err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
7430                if state.enabled {
7431                    if let Some(schedule) = state.next_crash_restart(&policy, now) {
7432                        final_state = ModuleState::Restarting;
7433                        restart_schedule = Some(schedule);
7434                        disposition = TerminalDisposition::Restarting;
7435                    } else {
7436                        final_state = ModuleState::Failed;
7437                        disposition = TerminalDisposition::Failed;
7438                        let budget = policy.budget_exhausted_detail();
7439                        disposition_detail =
7440                            Some(disposition_detail.take().map_or_else(
7441                                || budget.clone(),
7442                                |cause| format!("{cause}; {budget}"),
7443                            ));
7444                    }
7445                } else {
7446                    disposition = TerminalDisposition::Disabled;
7447                }
7448            }) {
7449                error!(module_id = %spec.module_id, error = %err, "failed to record crashed module exit");
7450                return NextAction::Stop {
7451                    registration_released: false,
7452                };
7453            }
7454            if disposition == TerminalDisposition::Failed {
7455                // The window is in the message, not only in the fields: this line
7456                // is read in a scrollback where a bare `max_restarts=3` reads as a
7457                // lifetime cap and sends the operator looking for three crashes
7458                // that never happened together.
7459                error!(
7460                    module_id = %spec.module_id,
7461                    max_restarts = policy.max_restarts,
7462                    window_secs = policy.window.as_secs(),
7463                    "module stopped: {}",
7464                    policy.budget_exhausted_detail()
7465                );
7466            }
7467            if let Err(err) = publish_after_terminal_record(
7468                &spec.module_id,
7469                || {
7470                    record_terminal_with_detail(
7471                        &spec.module_id,
7472                        terminal_ring,
7473                        spawn_events,
7474                        &exit_report,
7475                        disposition,
7476                        disposition_detail,
7477                    )
7478                },
7479                || {
7480                    update_snapshot(snapshot, Some(&spec.module_id), |state| {
7481                        state.state = final_state;
7482                        clear_current_process_facts(state);
7483                        state.last_exit = Some(exit_report.clone());
7484                    })
7485                },
7486            ) {
7487                error!(module_id = %spec.module_id, error = %err, "failed to publish crashed module exit");
7488            }
7489
7490            if let Some(schedule) = restart_schedule {
7491                NextAction::Restart {
7492                    schedule: Some(schedule),
7493                }
7494            } else {
7495                let registration_released = match wait_for_registration_release(
7496                    registry,
7497                    &spec.module_id,
7498                    REGISTRY_RELEASE_TIMEOUT,
7499                )
7500                .await
7501                {
7502                    Ok(()) => true,
7503                    Err(err) => {
7504                        warn!(module_id = %spec.module_id, error = %err, "registration still active after failed module");
7505                        false
7506                    }
7507                };
7508                NextAction::Stop {
7509                    registration_released,
7510                }
7511            }
7512        }
7513        ExitKind::DeliberateSeverance => {
7514            warn!(
7515                module_id = %spec.module_id,
7516                exit_code = ?exit_report.code,
7517                exit_signal = ?exit_report.signal,
7518                "supervised module exited after deliberate connection severance"
7519            );
7520            let should_restart = match lock_snapshot(snapshot) {
7521                Ok(state) => state.enabled,
7522                Err(err) => {
7523                    error!(module_id = %spec.module_id, error = %err, "failed to read deliberately severed module state");
7524                    return NextAction::Stop {
7525                        registration_released: false,
7526                    };
7527                }
7528            };
7529            let final_state = if should_restart {
7530                ModuleState::Restarting
7531            } else {
7532                ModuleState::Disabled
7533            };
7534            if let Err(err) = publish_after_terminal_record(
7535                &spec.module_id,
7536                || {
7537                    record_terminal(
7538                        &spec.module_id,
7539                        terminal_ring,
7540                        spawn_events,
7541                        &exit_report,
7542                        terminal_disposition(final_state),
7543                    )
7544                },
7545                || {
7546                    update_snapshot(snapshot, Some(&spec.module_id), |state| {
7547                        state.state = final_state;
7548                        clear_current_process_facts(state);
7549                        state.last_exit = Some(exit_report.clone());
7550                        state.lifetime_restarts += 1;
7551                    })
7552                },
7553            ) {
7554                error!(module_id = %spec.module_id, error = %err, "failed to publish deliberately severed module exit");
7555                return NextAction::Stop {
7556                    registration_released: false,
7557                };
7558            }
7559
7560            if should_restart {
7561                NextAction::Restart { schedule: None }
7562            } else {
7563                let registration_released = match wait_for_registration_release(
7564                    registry,
7565                    &spec.module_id,
7566                    REGISTRY_RELEASE_TIMEOUT,
7567                )
7568                .await
7569                {
7570                    Ok(()) => true,
7571                    Err(err) => {
7572                        warn!(module_id = %spec.module_id, error = %err, "registration still active after deliberately severed module exit");
7573                        false
7574                    }
7575                };
7576                NextAction::Stop {
7577                    registration_released,
7578                }
7579            }
7580        }
7581    }
7582}
7583
7584async fn on_child_exit_during_daemon_shutdown(
7585    spec: &ModuleSpec,
7586    registry: &Registry,
7587    snapshot: &SharedSnapshot,
7588    terminal_ring: &Arc<Mutex<TerminalRing>>,
7589    spawn_events: &SpawnEventFeed,
7590    exit_report: ExitReport,
7591) -> NextAction {
7592    info!(
7593        module_id = %spec.module_id,
7594        exit_code = ?exit_report.code,
7595        exit_signal = ?exit_report.signal,
7596        exit_kind = ?exit_report.kind,
7597        "supervised module exited during daemon shutdown; not restarting it"
7598    );
7599    if let Err(err) = publish_after_terminal_record(
7600        &spec.module_id,
7601        || {
7602            record_terminal(
7603                &spec.module_id,
7604                terminal_ring,
7605                spawn_events,
7606                &exit_report,
7607                TerminalDisposition::DaemonShutdown,
7608            )
7609        },
7610        || {
7611            update_snapshot(snapshot, Some(&spec.module_id), |state| {
7612                state.state = ModuleState::Stopped;
7613                clear_current_process_facts(state);
7614                state.last_exit = Some(exit_report.clone());
7615            })
7616        },
7617    ) {
7618        error!(module_id = %spec.module_id, error = %err, "failed to publish module exit during daemon shutdown");
7619    }
7620    let registration_released =
7621        wait_for_registration_release(registry, &spec.module_id, REGISTRY_RELEASE_TIMEOUT)
7622            .await
7623            .is_ok();
7624    NextAction::Stop {
7625        registration_released,
7626    }
7627}
7628
7629// Keep the status lock out of terminal I/O. Readers may observe the previous
7630// state while recording is in progress, but never a completed exit without its
7631// history. Journal errors are logged by the journal and retained in the ring;
7632// a recording panic still publishes the final state before resuming the unwind.
7633fn publish_after_terminal_record<T>(
7634    module_id: &str,
7635    record: impl FnOnce(),
7636    publish: impl FnOnce() -> T,
7637) -> T {
7638    let recorded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(record));
7639    let published = publish();
7640    if let Err(panic) = recorded {
7641        error!(
7642            module_id,
7643            "terminal recording panicked; published final state before resuming unwind"
7644        );
7645        std::panic::resume_unwind(panic);
7646    }
7647    published
7648}
7649
7650fn fail_after_wait_error(
7651    module_id: &str,
7652    snapshot: &SharedSnapshot,
7653    terminal_ring: &Arc<Mutex<TerminalRing>>,
7654    spawn_events: &SpawnEventFeed,
7655    detail: String,
7656) {
7657    let report = wait_error_exit_report();
7658    publish_after_terminal_record(
7659        module_id,
7660        || {
7661            record_terminal_with_detail(
7662                module_id,
7663                terminal_ring,
7664                spawn_events,
7665                &report,
7666                TerminalDisposition::Failed,
7667                Some(detail),
7668            )
7669        },
7670        || fail_snapshot(snapshot, Some(module_id), Some(report.clone())),
7671    );
7672}
7673
7674#[cfg(test)]
7675fn record_wait_error_terminal(
7676    module_id: &str,
7677    terminal_ring: &Arc<Mutex<TerminalRing>>,
7678    spawn_events: &SpawnEventFeed,
7679) {
7680    record_terminal(
7681        module_id,
7682        terminal_ring,
7683        spawn_events,
7684        &wait_error_exit_report(),
7685        TerminalDisposition::Failed,
7686    );
7687}
7688
7689fn record_terminal(
7690    module_id: &str,
7691    terminal_ring: &Arc<Mutex<TerminalRing>>,
7692    spawn_events: &SpawnEventFeed,
7693    exit_report: &ExitReport,
7694    disposition: TerminalDisposition,
7695) {
7696    record_terminal_with_detail(
7697        module_id,
7698        terminal_ring,
7699        spawn_events,
7700        exit_report,
7701        disposition,
7702        None,
7703    );
7704}
7705
7706/// The ring lock is held only to capture the read (see
7707/// `TerminalJournal::capture_read`), so this module's exits keep recording
7708/// while the journal files are read. Blocking: it reads files.
7709fn durable_terminal_history_of(
7710    terminal_ring: &Mutex<TerminalRing>,
7711    module_id: &str,
7712) -> subc_control::TerminalHistory {
7713    let read = terminal_ring
7714        .lock()
7715        .unwrap_or_else(|p| p.into_inner())
7716        .capture_durable_history();
7717    read.read(module_id)
7718}
7719
7720fn record_terminal_with_detail(
7721    module_id: &str,
7722    terminal_ring: &Arc<Mutex<TerminalRing>>,
7723    spawn_events: &SpawnEventFeed,
7724    exit_report: &ExitReport,
7725    disposition: TerminalDisposition,
7726    disposition_detail: Option<String>,
7727) {
7728    #[cfg(test)]
7729    terminal_history_tests::before_terminal_record(spawn_events);
7730    spawn_events.emit_exited(module_id, exit_report.code, exit_report.signal);
7731    let record = TerminalRecord {
7732        exit_code: exit_report.code,
7733        exit_signal: exit_report.signal,
7734        at_ms: exit_report.at_ms,
7735        disposition,
7736        exit_kind: exit_report.kind.into(),
7737        disposition_detail,
7738    };
7739    terminal_ring
7740        .lock()
7741        .unwrap_or_else(|poisoned| poisoned.into_inner())
7742        .record_exit(module_id, record);
7743}
7744
7745fn untrack_if_registration_released(
7746    process_liveness: &SupervisorProcessLiveness,
7747    registry: &Registry,
7748    module_id: &str,
7749    snapshot: &SharedSnapshot,
7750) {
7751    match registry.get_module(module_id) {
7752        Ok(None) => process_liveness.untrack_if_current(module_id, snapshot),
7753        Ok(Some(_)) => {}
7754        Err(err) => {
7755            warn!(module_id, error = %err, "could not determine whether supervisor liveness can be untracked");
7756        }
7757    }
7758}
7759
7760/// The child's environment plan: inherit the parent's, drop ambient `CK_LOG`,
7761/// then apply the module's configured entries minus daemon-private capture keys.
7762///
7763/// Separated from `spawn_child` only so it can be asserted without spawning a
7764/// process — a duplicate of this logic in a test would pass while the real one
7765/// drifted, which is the defect class this function exists to avoid.
7766/// The subc-wire half of a spawn: `--subc <connection file>` and the launch
7767/// nonce. A `protocol: "none"` module gets neither, because it cannot use
7768/// either and the argument would stop a stock binary from starting at all.
7769/// `SUBC_MODULE_ID` is set on every path since an unread variable is inert.
7770///
7771/// The plain-spawn form, kept for the tests that assert its plan; spawns go
7772/// through [`apply_wire_spawn_args_for_role`].
7773#[cfg(test)]
7774fn apply_wire_spawn_args(
7775    command: &mut Command,
7776    spec: &ModuleSpec,
7777    connection_file_path: Option<&std::path::Path>,
7778    handle: Option<&SupervisorHandle>,
7779) -> Result<Option<NonceHandoff>, SuperviseError> {
7780    apply_wire_spawn_args_for_role(
7781        command,
7782        spec,
7783        connection_file_path,
7784        handle,
7785        SpawnRole::Plain,
7786    )
7787}
7788
7789/// The read end of a spawn's launch-nonce pipe, prepared by
7790/// [`apply_wire_spawn_args_for_role`] and installed as the child's descriptor 3
7791/// by the last pre-exec step, just before `spawn()`. Windows prepares a named
7792/// server before spawn and authenticates its client after the child exists.
7793#[cfg(unix)]
7794type NonceHandoff = subc_os::LaunchNonceHandoff;
7795#[cfg(windows)]
7796type NonceHandoff = subc_os::LaunchNoncePipeHandoff;
7797#[cfg(not(any(unix, windows)))]
7798type NonceHandoff = std::convert::Infallible;
7799
7800/// Create the launch nonce that a new module process must present when it
7801/// registers or opens a consumer connection, including a replacement process
7802/// started alongside a still-serving module.
7803///
7804/// A plain spawn replaces the module's recorded nonce. A swap candidate records
7805/// a separate candidate token so the still-serving incumbent and its consumers
7806/// keep their nonce. Both records are installed before the process exists, so
7807/// the child's initial HELLO registration cannot arrive ahead of its nonce.
7808///
7809/// On Unix the nonce is delivered only through a pipe. It is written into
7810/// a pipe whose read end the child gets as descriptor 3, named by
7811/// `SUBC_LAUNCH_NONCE_FD=3:<pipe inode>`: unlike the environment, another
7812/// process of the same user cannot read it with `ps eww`. That handoff is
7813/// returned rather than installed here, because installing it replaces
7814/// whatever the child has at descriptor 3 and so must be the last pre-exec
7815/// step. Later in `spawn_child_in_slot`, `apply_cgroup_placement` first registers
7816/// the Linux callback that writes the process into its cgroup. Only after that
7817/// does `handoff.install_last` register the descriptor-3 replacement, so it
7818/// cannot close a descriptor the cgroup callback still needs.
7819/// Windows keeps SUBC_LAUNCH_NONCE as well until every module reads the named pipe.
7820fn apply_wire_spawn_args_for_role(
7821    command: &mut Command,
7822    spec: &ModuleSpec,
7823    connection_file_path: Option<&std::path::Path>,
7824    handle: Option<&SupervisorHandle>,
7825    role: SpawnRole,
7826) -> Result<Option<NonceHandoff>, SuperviseError> {
7827    command.env(SUBC_MODULE_ID_ENV, &spec.module_id);
7828    // SUBC_LAUNCH_NONCE_FD is removed for every child, `protocol: "none"`
7829    // included: a daemon started from a module's process tree inherits it,
7830    // and passing it on would point the child at a descriptor it does not
7831    // have.
7832    command.env_remove(subc_os::LAUNCH_NONCE_FD_ENV);
7833    #[cfg(windows)]
7834    {
7835        command.env_remove(subc_os::LAUNCH_NONCE_PIPE_ENV);
7836        command.env_remove(subc_os::LAUNCH_NONCE_PIPE_FALLBACK_ENV);
7837    }
7838    // Remove inherited or configured copies too: withholding must mean absent.
7839    command.env_remove(SUBC_LAUNCH_NONCE_ENV);
7840    if spec.protocol == ModuleProtocol::None {
7841        return Ok(None);
7842    }
7843    if let Some(connection_file_path) = connection_file_path {
7844        command.arg(SUBC_ARG).arg(connection_file_path);
7845    }
7846
7847    // Every subc-wire spawn receives a fresh one-time launch nonce for consumer
7848    // route.open attestation. Reserved modules additionally use the same nonce
7849    // for HELLO id-squatting protection. A respawn rotates both records.
7850    let nonce = generate_launch_nonce()?;
7851    if let Some(handle) = handle {
7852        match role {
7853            SpawnRole::Plain => {
7854                handle.set_spawn_nonce(&spec.module_id, nonce.clone());
7855                if spec.reserved {
7856                    handle.set_reserved_nonce(&spec.module_id, nonce.clone());
7857                }
7858            }
7859            SpawnRole::SwapCandidate => handle.open_swap(&spec.module_id, nonce.clone()),
7860        }
7861    }
7862    #[cfg(unix)]
7863    let handoff = {
7864        let handoff =
7865            subc_os::LaunchNonceHandoff::new(&nonce).map_err(|source| SuperviseError::Spawn {
7866                program: spec.program.clone(),
7867                source,
7868                cgroup_path: None,
7869            })?;
7870        command.env(subc_os::LAUNCH_NONCE_FD_ENV, handoff.fd_env_value());
7871        Some(handoff)
7872    };
7873    #[cfg(windows)]
7874    let handoff = prepare_windows_nonce_pipe(command, spec, &nonce);
7875    #[cfg(not(any(unix, windows)))]
7876    let handoff = None;
7877    // Older modules still read SUBC_LAUNCH_NONCE to register. Keep that copy
7878    // until the daemon's per-spawn delivery records show that every running
7879    // Windows module has read its secret from the named pipe.
7880    #[cfg(not(unix))]
7881    command.env(SUBC_LAUNCH_NONCE_ENV, nonce);
7882    Ok(handoff)
7883}
7884
7885fn apply_child_env(command: &mut Command, spec: &ModuleSpec) {
7886    command.env_remove(CK_LOG_ENV);
7887    // The spawn role is the supervisor's to set, and only on a swap candidate
7888    // (see `apply_spawn_role`). Removing it here, rather than just not setting
7889    // it, is what makes it absent on a plain spawn: the daemon's own
7890    // environment could carry it, and so could a spec built outside daemon
7891    // config (config refuses it as an `env` key). A module reading it on a
7892    // plain restart would pick the long swap budget and leave callers waiting.
7893    command.env_remove(SUBC_SPAWN_ROLE_ENV);
7894    for (key, value) in &spec.env {
7895        // cortexkit-log currently exposes retention only as a Rust struct, not
7896        // environment names. These values are daemon-private sink metadata and
7897        // must never become a public child-process contract by being inherited.
7898        if matches!(
7899            key.as_str(),
7900            CAPTURE_MAX_FILE_MB_ENV | CAPTURE_KEEP_ENV | CAPTURE_MAX_AGE_DAYS_ENV
7901        ) || key == SUBC_SPAWN_ROLE_ENV
7902        {
7903            continue;
7904        }
7905        command.env(key, value);
7906    }
7907}
7908
7909#[cfg(windows)]
7910fn prepare_windows_nonce_pipe(
7911    command: &mut Command,
7912    spec: &ModuleSpec,
7913    nonce: &str,
7914) -> Option<NonceHandoff> {
7915    match subc_os::LaunchNoncePipeHandoff::new(nonce) {
7916        Ok(handoff) => {
7917            command.env(subc_os::LAUNCH_NONCE_PIPE_ENV, handoff.name());
7918            // Let a failed pipe read use SUBC_LAUNCH_NONCE so the process can
7919            // still register until every module reads the pipe. A reader without
7920            // this variable set to env returns the pipe error instead of using
7921            // an inherited or configured environment copy.
7922            command.env(subc_os::LAUNCH_NONCE_PIPE_FALLBACK_ENV, "env");
7923            Some(handoff)
7924        }
7925        Err(error) => {
7926            warn!(module_id = %spec.module_id, %error, "could not prepare launch nonce pipe; retaining the environment handoff");
7927            None
7928        }
7929    }
7930}
7931
7932#[cfg(windows)]
7933fn start_windows_nonce_pipe(
7934    handoff: Option<NonceHandoff>,
7935    pid: u32,
7936    spec: &ModuleSpec,
7937) -> Option<subc_os::LaunchNoncePipeDelivery> {
7938    // Close an unread pipe after REGISTRY_RELEASE_TIMEOUT (one second), the
7939    // same maximum wait used for registration during a reload. Older modules
7940    // may register using SUBC_LAUNCH_NONCE without ever connecting to the pipe.
7941    handoff.and_then(|handoff| match handoff.serve(pid, REGISTRY_RELEASE_TIMEOUT) {
7942        Ok(delivery) => Some(delivery),
7943        Err(error) => {
7944            warn!(module_id = %spec.module_id, %error, "could not serve launch nonce pipe; retaining the environment handoff");
7945            None
7946        }
7947    })
7948}
7949
7950fn observed_launch_nonce_source(snapshot: &SupervisorSnapshot) -> Option<String> {
7951    #[cfg(windows)]
7952    if snapshot.process_alive && snapshot.spawned_protocol == Some(ModuleProtocol::Subc) {
7953        return Some(
7954            if snapshot
7955                .pipe_consumed
7956                .as_ref()
7957                .is_some_and(|consumed| consumed.get().is_some())
7958            {
7959                "pipe"
7960            } else {
7961                "env"
7962            }
7963            .to_owned(),
7964        );
7965    }
7966    let _ = snapshot;
7967    None
7968}
7969
7970/// Which slot a spawn fills: the module's ordinary one, or the candidate slot
7971/// of a blue/green swap.
7972#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7973enum SpawnRole {
7974    Plain,
7975    SwapCandidate,
7976}
7977
7978/// Set the spawn role for a swap candidate. A plain spawn gets nothing here;
7979/// `apply_child_env` has already removed the variable for every spawn.
7980fn apply_spawn_role(command: &mut Command, role: SpawnRole) {
7981    if role == SpawnRole::SwapCandidate {
7982        command.env(SUBC_SPAWN_ROLE_ENV, SPAWN_ROLE_SWAP_CANDIDATE);
7983    }
7984}
7985
7986#[cfg(target_os = "linux")]
7987fn previous_instance_error(spec: &ModuleSpec, pid: u32) -> SuperviseError {
7988    SuperviseError::Spawn {
7989        program: spec.program.clone(),
7990        source: io::Error::new(io::ErrorKind::WouldBlock, format!(
7991            "waiting: previous instance (pid {pid}) is still running but hidden; it cannot be identified"
7992        )),
7993        cgroup_path: None,
7994    }
7995}
7996
7997fn spawn_child(
7998    spec: &ModuleSpec,
7999    connection_file_path: Option<&std::path::Path>,
8000    handle: Option<&SupervisorHandle>,
8001    ring: &Arc<Mutex<StderrRing>>,
8002    capture_logs_dir: Option<&std::path::Path>,
8003    roster: &ChildRoster,
8004    #[cfg(target_os = "linux")] cgroup_placement: Option<&subc_cgroup::Placement>,
8005) -> Result<SupervisedChild, SuperviseError> {
8006    spawn_child_in_slot(
8007        spec,
8008        connection_file_path,
8009        handle,
8010        ring,
8011        capture_logs_dir,
8012        roster,
8013        #[cfg(target_os = "linux")]
8014        cgroup_placement,
8015        SpawnRole::Plain,
8016        false,
8017    )
8018}
8019
8020/// Spawn one process of `spec` into a slot.
8021///
8022/// `alternate_slot` picks the process's cgroup label on Linux (see `swap::cgroup_name`).
8023/// A swap candidate needs a different cgroup from the process it is replacing,
8024/// which is still alive: in the same cgroup the two would be one kill domain,
8025/// and killing a failed candidate could take the incumbent with it.
8026///
8027/// The stderr capture file is `<module_id>.stderr.log` for every process of
8028/// the module, whichever slot it is in, because that is the one file
8029/// `ck module logs` reads. During a swap's overlap both processes append to it;
8030/// the daemon writes whole lines, so the two interleave by line, which is also
8031/// the merged view an operator wants while a swap runs.
8032#[allow(clippy::too_many_arguments)]
8033fn spawn_child_in_slot(
8034    spec: &ModuleSpec,
8035    connection_file_path: Option<&std::path::Path>,
8036    handle: Option<&SupervisorHandle>,
8037    ring: &Arc<Mutex<StderrRing>>,
8038    capture_logs_dir: Option<&std::path::Path>,
8039    roster: &ChildRoster,
8040    #[cfg(target_os = "linux")] cgroup_placement: Option<&subc_cgroup::Placement>,
8041    role: SpawnRole,
8042    alternate_slot: bool,
8043) -> Result<SupervisedChild, SuperviseError> {
8044    #[cfg(target_os = "linux")]
8045    if let Some(pid) = roster.previous_instance_pid(&spec.module_id) {
8046        return Err(previous_instance_error(spec, pid));
8047    }
8048    if roster.is_closed() {
8049        return Err(SuperviseError::Spawn {
8050            program: spec.program.clone(),
8051            source: io::Error::other("the daemon is shutting down; not starting a new process"),
8052            cgroup_path: None,
8053        });
8054    }
8055    #[cfg(target_os = "linux")]
8056    let cgroup_name = {
8057        // Slot names alone are not kill domains: a retired incumbent may still
8058        // be draining when a later enable/restart spawns into the same slot.
8059        // Decimal entropy keeps the suffix unambiguous; Placement performs
8060        // the module-id escaping and constructs the filesystem path.
8061        if cgroup_placement.is_none() {
8062            swap::cgroup_name(&spec.module_id, alternate_slot)
8063        } else {
8064            let nonce = generate_launch_nonce()?;
8065            let suffix = u128::from_str_radix(&nonce[..32], 16).expect("hex launch nonce");
8066            // Leave room for byte escaping and the suffix under NAME_MAX. The
8067            // label is only for humans; the nonce identifies the kill domain.
8068            let mut end = spec.module_id.len().min(64);
8069            while !spec.module_id.is_char_boundary(end) {
8070                end -= 1;
8071            }
8072            format!(
8073                "{}_{suffix}",
8074                swap::cgroup_name(&spec.module_id[..end], alternate_slot)
8075            )
8076        }
8077    };
8078    #[cfg(not(target_os = "linux"))]
8079    let _ = alternate_slot;
8080    #[cfg(target_os = "macos")]
8081    let (mut command, privacy_exec, exec_ack) = privacy_command(spec, roster)?;
8082    #[cfg(windows)]
8083    let image_capture = windows_spawn_capture(spec).map_err(|source| SuperviseError::Spawn {
8084        program: spec.program.clone(),
8085        source,
8086        cgroup_path: None,
8087    })?;
8088    #[cfg(windows)]
8089    let mut command = Command::new(image_capture.path());
8090    #[cfg(not(any(target_os = "macos", windows)))]
8091    let mut command = Command::new(&spec.program);
8092    command.args(&spec.args);
8093    // AMBIENT `CK_LOG` MUST NOT LEAK INTO AN OTHERWISE UNCONFIGURED MODULE — but
8094    // that is the whole of the intent, so remove that one key rather than the
8095    // environment.
8096    //
8097    // This was `env_clear()` from 0.17.41 until 0.18.3, which achieved the goal
8098    // and took the POSIX environment with it. Modules spawned that way had no
8099    // HOME, XDG_RUNTIME_DIR, TMPDIR or USER, and the consequences ran past
8100    // logging:
8101    //
8102    //   * `connection_file::discover` reads XDG_RUNTIME_DIR and HOME, so with
8103    //     both unset it fell back to the temp dir alone and `ck` could not find
8104    //     a daemon running on the same machine from inside any module's process
8105    //     tree — reporting a path the file has never lived at, which reads as
8106    //     "the daemon did not write its file".
8107    //   * `default_data_home()` with HOME and XDG_DATA_HOME both unset returns
8108    //     the RELATIVE `.local/share`, so a module deriving its own store path
8109    //     resolved it against its own CWD. That is the store-fragmentation
8110    //     defect the daemon already refuses in config (`parse_doc` rejects a
8111    //     relative `storage.data_home`) arriving by derivation instead.
8112    //   * anything a module spawns inherited it: git without ~/.gitconfig,
8113    //     cargo without CARGO_HOME, ssh, python user dirs — all degrading
8114    //     quietly rather than erroring.
8115    //
8116    // Reported by iceteaSA as #104 after deploying 0.18.2, where `ck daemon`
8117    // offered one candidate under /tmp while the file sat in /run/user/1000.
8118    //
8119    // A configured module is unaffected either way: `module_spec()` puts the
8120    // resolved CK_LOG into `spec.env`, which is applied below and therefore
8121    // wins over anything ambient.
8122    apply_child_env(&mut command, spec);
8123    apply_spawn_role(&mut command, role);
8124    let nonce_handoff =
8125        apply_wire_spawn_args_for_role(&mut command, spec, connection_file_path, handle, role)?;
8126
8127    #[cfg(target_os = "linux")]
8128    let cgroup_path = cgroup_placement
8129        .map(|placement| placement.module_path(&cgroup_name))
8130        .transpose()
8131        .map_err(|source| SuperviseError::Cgroup {
8132            module_id: spec.module_id.clone(),
8133            source,
8134        })?;
8135    #[cfg(not(target_os = "linux"))]
8136    let cgroup_path: Option<PathBuf> = None;
8137    #[cfg(target_os = "linux")]
8138    if let Some(path) = &cgroup_path {
8139        if let Err(error) = apply_cgroup_placement(&mut command, spec, path) {
8140            if let Some(placement) = cgroup_placement {
8141                remove_module_cgroup(placement, &cgroup_name);
8142            }
8143            return Err(error);
8144        }
8145    }
8146
8147    let output_sink = if let Some(logs_dir) = capture_logs_dir {
8148        let path = logs_dir.join(format!("{}.stderr.log", spec.module_id));
8149        match ChildOutputSink::open(&path, capture_retention(spec)) {
8150            Ok(sink) => sink,
8151            Err(error) => {
8152                warn!(
8153                    module_id = %spec.module_id,
8154                    path = %path.display(),
8155                    error = %error,
8156                    "could not open child output capture file; forwarding to stderr"
8157                );
8158                ChildOutputSink::Stderr
8159            }
8160        }
8161    } else {
8162        ChildOutputSink::Stderr
8163    };
8164
8165    command.stdout(Stdio::piped());
8166    command.stderr(Stdio::piped());
8167    command.kill_on_drop(true);
8168    // EACH MODULE LEADS ITS OWN PROCESS GROUP (the child calls setpgid(0, 0)
8169    // before exec). In the daemon's group, a service manager that kills the
8170    // job's process group when the daemon exits (launchd's default) killed
8171    // every module at the same moment its control connection closed, so no
8172    // module ever ran its EOF teardown on a daemon stop. Outside that group a
8173    // module is reached only by the daemon: the EOF it sees when its
8174    // connection closes, and the bounded stop in `child_roster` for anything
8175    // still running after that. On Linux this composes with the cgroup
8176    // placement above: that is a pre_exec write to cgroup.procs, std performs
8177    // setpgid in the child before running pre_exec callbacks, and the two
8178    // change independent process attributes.
8179    //
8180    // stdin is /dev/null because a process outside the terminal's foreground
8181    // group is stopped (SIGTTIN) if it reads the terminal, which a daemon run
8182    // by hand would otherwise hand down. Under a service manager stdin is
8183    // already /dev/null.
8184    #[cfg(unix)]
8185    command.process_group(0);
8186    command.stdin(Stdio::null());
8187    // The LAST pre-exec step, after the cgroup placement above: installing the
8188    // nonce at descriptor 3 replaces whatever the child had there, which could
8189    // be the descriptor an earlier step writes through.
8190    #[cfg(unix)]
8191    if let Some(handoff) = nonce_handoff {
8192        handoff.install_last(command.as_std_mut());
8193    }
8194    #[cfg(not(any(unix, windows)))]
8195    let _ = nonce_handoff;
8196
8197    // Containment, step 1 of 3 (issue #109): create the child suspended so it
8198    // cannot run a single instruction -- and therefore cannot spawn a
8199    // grandchild -- before it is in the job. See `contain_spawned_child` for the
8200    // other two steps and why the window matters.
8201    #[cfg(windows)]
8202    subc_jobobject::suspend_on_create_async(&mut command);
8203    let mut child = match command.spawn() {
8204        Ok(child) => child,
8205        Err(source) => {
8206            #[cfg(target_os = "linux")]
8207            if let Some(placement) = cgroup_placement {
8208                remove_module_cgroup(placement, &cgroup_name);
8209            }
8210            return Err(SuperviseError::Spawn {
8211                program: spec.program.clone(),
8212                source,
8213                cgroup_path,
8214            });
8215        }
8216    };
8217    // The parent must close its writer now: the acknowledgement pipe reports EOF
8218    // only when every writer is gone, and the child's copy closes when the
8219    // trampoline replaces itself with the module. Command holds only an integer
8220    // in its pre_exec callback, not another writer.
8221    #[cfg(target_os = "macos")]
8222    drop(exec_ack);
8223
8224    // Bind while the child is still suspended, before job assignment resumes it.
8225    // Keeping the executable handle open across CreateProcessW denies rename
8226    // and deletion, so the file cannot be exchanged during spawn. Bind that
8227    // file ID to the process handle returned by spawn, never a reopened PID.
8228    #[cfg(windows)]
8229    let windows_image =
8230        Arc::new(
8231            image_capture
8232                .bind_tokio(&child)
8233                .map_err(|source| SuperviseError::Spawn {
8234                    program: spec.program.clone(),
8235                    source,
8236                    cgroup_path: None,
8237                })?,
8238        );
8239    // Containment, steps 2 and 3: assign while suspended, then resume.
8240    #[cfg(windows)]
8241    let job = contain_spawned_child(&child, spec)?;
8242    let spawned_at_ms = unix_ms_now();
8243    #[cfg(not(windows))]
8244    let spawned_from = spec.program.clone();
8245    #[cfg(windows)]
8246    let spawned_from = windows_image.path().to_path_buf();
8247    #[cfg(not(windows))]
8248    let spawned_file_identity = spawned_file_identity(&spawned_from);
8249    #[cfg(windows)]
8250    let spawned_file_identity = Some(SpawnedFileIdentity {
8251        device: 0,
8252        inode: 0,
8253        windows_image: Some(Arc::clone(&windows_image)),
8254    });
8255    let pid = child.id().ok_or_else(|| SuperviseError::Spawn {
8256        program: spec.program.clone(),
8257        source: io::Error::other("spawned child exposed no live pid"),
8258        cgroup_path: cgroup_path.clone(),
8259    })?;
8260    #[cfg(windows)]
8261    let nonce_delivery = start_windows_nonce_pipe(nonce_handoff, pid, spec);
8262    #[cfg(not(windows))]
8263    let process_start_time = crate::provenance::process_start_time(pid);
8264    #[cfg(windows)]
8265    let process_start_time = Some(windows_image.start_time());
8266    let process_identity = process_start_time.map(|start_time| ProcessIdentity { pid, start_time });
8267    #[cfg(all(test, target_os = "macos"))]
8268    privacy_exec_boundary_tests::before_image_sample(spec, pid);
8269    // Unix spawn returns after exec's error pipe closes. The kernel image is
8270    // therefore the executable to compare during a future orphan sweep: PATH
8271    // lookup and shebang interpretation may select a different file from the
8272    // configured program. Keep the literal program's identity for provenance,
8273    // but never use it as proof that a recorded pid may be signalled.
8274    #[cfg(not(windows))]
8275    let recorded_image = observe_spawned_image(pid);
8276    // spawn() confirms only the first exec, into the trampoline. Never persist
8277    // the trampoline image; the asynchronous acknowledgement publishes the
8278    // module image once the trampoline has replaced itself with the module.
8279    #[cfg(target_os = "macos")]
8280    let recorded_image = if privacy_exec.is_some() {
8281        None
8282    } else {
8283        recorded_image
8284    };
8285    #[cfg(target_os = "linux")]
8286    let recorded_cgroup_name = cgroup_path.as_ref().map(|_| cgroup_name.clone());
8287    #[cfg(not(target_os = "linux"))]
8288    let recorded_cgroup_name = None;
8289    let roster_guard = roster.admit(
8290        spec.module_id.clone(),
8291        pid,
8292        spec.protocol,
8293        process_start_time,
8294        crate::child_roster::RecordedIdentity {
8295            #[cfg(not(windows))]
8296            start_time: recorded_image.map(|image| image.start_time),
8297            #[cfg(windows)]
8298            start_time: process_start_time,
8299            #[cfg(not(windows))]
8300            executable: recorded_image
8301                .and_then(|image| image.executable)
8302                .map(crate::live_children::ExecutableIdentity::from),
8303            #[cfg(windows)]
8304            executable: Some(crate::live_children::ExecutableIdentity::from(
8305                windows_image.identity(),
8306            )),
8307            cgroup_name: recorded_cgroup_name,
8308            #[cfg(target_os = "linux")]
8309            cgroup_placement: cgroup_placement.cloned(),
8310        },
8311    );
8312    // The check at the top of this function can pass just before daemon
8313    // shutdown begins, and the process is only in the roster from here on.
8314    // The shutdown stop returns as soon as it finds the roster empty, so a
8315    // process admitted after that look would outlive the daemon. The roster
8316    // is closed before the stop first reads it and admission happens under
8317    // the roster's lock, so either the stop sees this process or this check
8318    // sees the roster closed: end the process now rather than start a module
8319    // the daemon is about to stop.
8320    if roster.is_closed() {
8321        // This child was never admitted, so there is no module protocol shutdown to wait for.
8322        #[cfg(target_os = "linux")]
8323        kill_module_cgroup(cgroup_placement, &cgroup_name);
8324        if let Err(error) = child.start_kill() {
8325            debug!(module_id = %spec.module_id, pid, %error, "kill of a process spawned during daemon shutdown failed; it may already have exited");
8326        }
8327        #[cfg(target_os = "linux")]
8328        if let Some(placement) = cgroup_placement {
8329            // This spawn was never admitted, so shutdown has no roster entry
8330            // to await. Do not detach its cleanup: the runtime could exit
8331            // before that task reaps the rejected child and removes its group.
8332            while matches!(child.try_wait(), Ok(None)) {
8333                std::thread::yield_now();
8334            }
8335            if matches!(
8336                subc_cgroup::kill_module(Some(placement), &cgroup_name),
8337                subc_cgroup::KillOutcome::Killed
8338            ) {
8339                if let Ok(path) = placement.module_path(&cgroup_name) {
8340                    while std::fs::read_to_string(path.join("cgroup.events"))
8341                        .ok()
8342                        .is_some_and(|events| events.lines().any(|line| line == "populated 1"))
8343                    {
8344                        std::thread::yield_now();
8345                    }
8346                }
8347            }
8348            remove_module_cgroup(placement, &cgroup_name);
8349        }
8350        drop(roster_guard);
8351        return Err(SuperviseError::Spawn {
8352            program: spec.program.clone(),
8353            source: io::Error::other(
8354                "the daemon began shutting down while this process was starting; ended it",
8355            ),
8356            cgroup_path,
8357        });
8358    }
8359
8360    let stdout_pump = match child.stdout.take() {
8361        Some(stdout) => Some(tokio::spawn(pump_stdout_to(stdout, output_sink.clone()))),
8362        None => {
8363            warn!(
8364                module_id = %spec.module_id,
8365                "spawned child exposed no stdout pipe; file capture will be incomplete"
8366            );
8367            None
8368        }
8369    };
8370    let stderr_pump = match child.stderr.take() {
8371        Some(stderr) => {
8372            let generation = ring
8373                .lock()
8374                .unwrap_or_else(|poisoned| poisoned.into_inner())
8375                .begin_process();
8376            Some(StderrPump {
8377                task: tokio::spawn(pump_stderr_to(
8378                    stderr,
8379                    Arc::clone(ring),
8380                    generation,
8381                    output_sink,
8382                )),
8383                generation,
8384            })
8385        }
8386        None => {
8387            // Spawning succeeded but the pipe did not materialise. Recording it as
8388            // uncaptured keeps the tail honest: the alternative is an empty tail
8389            // that reads as a module which printed nothing.
8390            ring.lock()
8391                .unwrap_or_else(|poisoned| poisoned.into_inner())
8392                .mark_not_captured("stderr pipe was not available on spawn");
8393            warn!(
8394                module_id = %spec.module_id,
8395                "spawned child exposed no stderr pipe; tail will be unavailable"
8396            );
8397            None
8398        }
8399    };
8400
8401    Ok(SupervisedChild {
8402        child,
8403        #[cfg(windows)]
8404        nonce_delivery,
8405        protocol: spec.protocol,
8406        #[cfg(target_os = "linux")]
8407        module_id: cgroup_name,
8408        #[cfg(target_os = "linux")]
8409        cgroup_placement: cgroup_placement.cloned(),
8410        #[cfg(windows)]
8411        job,
8412        stdout_pump,
8413        stderr_pump,
8414        stderr_ring: Arc::clone(ring),
8415        spawned_at_ms,
8416        spawned_from,
8417        spawned_file_identity,
8418        process_start_time,
8419        process_identity,
8420        pid,
8421        roster_guard: Some(roster_guard),
8422        #[cfg(target_os = "macos")]
8423        privacy_exec,
8424        #[cfg(target_os = "macos")]
8425        report_ready: Arc::new(OnceLock::new()),
8426        spawn_failure: None,
8427    })
8428}
8429
8430#[cfg(target_os = "linux")]
8431pub(crate) fn kill_module_cgroup(placement: Option<&subc_cgroup::Placement>, module_id: &str) {
8432    use subc_cgroup::KillOutcome;
8433    match subc_cgroup::kill_module(placement, module_id) {
8434        KillOutcome::Killed => {}
8435        KillOutcome::NotPlaced | KillOutcome::Unsupported => {
8436            debug!(
8437                module_id,
8438                "cgroup tree kill unavailable; using direct-child kill"
8439            );
8440        }
8441        KillOutcome::IoError { path, error } => {
8442            warn!(module_id, path = %path.display(), %error, "cgroup tree kill failed; using direct-child kill");
8443        }
8444    }
8445}
8446
8447/// Contain a freshly spawned Windows child and start it.
8448///
8449/// Steps 2 and 3 of the suspended-create contract: the job is created and the
8450/// child assigned **while it is still suspended** (step 1 is
8451/// `suspend_on_create_async` at the spawn site), then the child is resumed.
8452///
8453/// A child that is never resumed hangs forever holding a pid, so a resume
8454/// failure kills the child and fails the spawn rather than returning a
8455/// `SupervisedChild` that can never run.
8456///
8457/// An assignment failure is NOT fatal: an uncontained module behaves exactly as
8458/// it did before this existed, whereas refusing to start one would be a new
8459/// outage. It is logged at warn because it means a helper process could leak.
8460#[cfg(windows)]
8461fn contain_spawned_child(
8462    child: &Child,
8463    spec: &ModuleSpec,
8464) -> Result<Option<subc_jobobject::JobObject>, SuperviseError> {
8465    let module_id = spec.module_id.as_str();
8466    let Some(pid) = child.id() else {
8467        // The child exited between spawn and here. Its tree, if it made one,
8468        // needs no containment: nothing is left to contain.
8469        warn!(
8470            module_id,
8471            "spawned child had already exited before containment; no job object attached"
8472        );
8473        return Ok(None);
8474    };
8475
8476    let job = match subc_jobobject::JobObject::new() {
8477        Ok(job) => job,
8478        Err(source) => {
8479            warn!(
8480                module_id,
8481                error = %source,
8482                "could not create a job object; this module's helper processes will not be \
8483                 reaped on teardown"
8484            );
8485            // Resume regardless: leaving the child suspended would turn a
8486            // containment gap into a hung module.
8487            resume_suspended_child(pid, spec)?;
8488            return Ok(None);
8489        }
8490    };
8491
8492    if let Err(source) = job.assign(child) {
8493        warn!(
8494            module_id,
8495            error = %source,
8496            "could not assign the child to its job object; this module's helper processes \
8497             will not be reaped on teardown"
8498        );
8499        resume_suspended_child(pid, spec)?;
8500        return Ok(None);
8501    }
8502
8503    resume_suspended_child(pid, spec)?;
8504    Ok(Some(job))
8505}
8506
8507/// Resume a suspended child, killing it if it cannot be started.
8508///
8509/// A suspended process holds a pid and does nothing, so there is no useful
8510/// state to return: the caller gets an error and the spawn fails.
8511#[cfg(windows)]
8512fn resume_suspended_child(pid: u32, spec: &ModuleSpec) -> Result<(), SuperviseError> {
8513    if let Err(source) = subc_jobobject::resume_main_thread(pid) {
8514        // Kill it here rather than leaving a suspended process for the caller
8515        // to notice; `kill_on_drop` would eventually do this, but the module
8516        // would have been reported as running in between.
8517        let _ = std::process::Command::new("taskkill.exe")
8518            .args(["/PID", &pid.to_string(), "/T", "/F"])
8519            .stdin(Stdio::null())
8520            .stdout(Stdio::null())
8521            .stderr(Stdio::null())
8522            .status();
8523        return Err(SuperviseError::Spawn {
8524            program: spec.program.clone(),
8525            source,
8526            cgroup_path: None,
8527        });
8528    }
8529    Ok(())
8530}
8531
8532#[cfg(target_os = "linux")]
8533fn remove_module_cgroup(placement: &subc_cgroup::Placement, module_id: &str) {
8534    match placement.remove_module(module_id) {
8535        Ok(()) => debug!(module_id, "removed module cgroup after process exit"),
8536        Err(error) => warn!(
8537            module_id,
8538            error = %error,
8539            "could not remove module cgroup after process exit; continuing teardown"
8540        ),
8541    }
8542}
8543
8544#[cfg(target_os = "linux")]
8545async fn cleanup_reaped_cgroup(placement: &subc_cgroup::Placement, module_id: &str) {
8546    // Reaping the direct child is not proof its descendants exited. End the
8547    // residual tree and wait for the kernel's population fact before rmdir;
8548    // otherwise a successful parent wait leaks a directory on each restart.
8549    if matches!(
8550        subc_cgroup::kill_module(Some(placement), module_id),
8551        subc_cgroup::KillOutcome::Killed
8552    ) {
8553        if let Ok(path) = placement.module_path(module_id) {
8554            while std::fs::read_to_string(path.join("cgroup.events"))
8555                .ok()
8556                .is_some_and(|events| events.lines().any(|line| line == "populated 1"))
8557            {
8558                sleep(Duration::from_millis(1)).await;
8559            }
8560        }
8561    }
8562    remove_module_cgroup(placement, module_id);
8563}
8564
8565#[cfg(target_os = "linux")]
8566fn apply_cgroup_placement(
8567    command: &mut Command,
8568    spec: &ModuleSpec,
8569    path: &std::path::Path,
8570) -> Result<(), SuperviseError> {
8571    subc_cgroup::apply(command, path).map_err(|source| SuperviseError::Cgroup {
8572        module_id: spec.module_id.clone(),
8573        source,
8574    })
8575}
8576
8577fn capture_retention(spec: &ModuleSpec) -> Retention {
8578    let defaults = Retention::default();
8579    let value = |name: &str| {
8580        spec.env
8581            .iter()
8582            .rev()
8583            .find_map(|(key, value)| (key == name).then_some(value.as_str()))
8584    };
8585    Retention {
8586        max_file_mb: value(CAPTURE_MAX_FILE_MB_ENV)
8587            .and_then(|value| value.parse().ok())
8588            .unwrap_or(defaults.max_file_mb),
8589        keep: value(CAPTURE_KEEP_ENV)
8590            .and_then(|value| value.parse().ok())
8591            .unwrap_or(defaults.keep),
8592        max_age_days: value(CAPTURE_MAX_AGE_DAYS_ENV)
8593            .and_then(|value| value.parse().ok())
8594            .unwrap_or(defaults.max_age_days),
8595    }
8596}
8597
8598/// A fresh 256-bit CSPRNG launch nonce, lowercase hex. Used to bind a reserved
8599/// module's registration to the exact process the supervisor spawned.
8600fn generate_launch_nonce() -> Result<String, SuperviseError> {
8601    let mut bytes = [0u8; 32];
8602    getrandom::getrandom(&mut bytes).map_err(|source| SuperviseError::LaunchNonce {
8603        reason: source.to_string(),
8604    })?;
8605    let mut hex = String::with_capacity(64);
8606    for b in bytes {
8607        use std::fmt::Write;
8608        let _ = write!(hex, "{b:02x}");
8609    }
8610    Ok(hex)
8611}
8612
8613/// Constant-time byte comparison so a reserved-nonce mismatch leaks no timing
8614/// signal about how many leading bytes matched.
8615fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
8616    if a.len() != b.len() {
8617        return false;
8618    }
8619    let mut diff = 0u8;
8620    for (x, y) in a.iter().zip(b.iter()) {
8621        diff |= x ^ y;
8622    }
8623    diff == 0
8624}
8625
8626/// The kernel's image after an acknowledged exec, shared by ordinary launches
8627/// and privacy trampolines. PATH and shebang interpretation are kernel facts,
8628/// not identities inferred from a configured pathname.
8629#[cfg(not(windows))]
8630fn observe_spawned_image(pid: u32) -> Option<subc_os::Observation> {
8631    subc_os::Process::open(pid)
8632        .ok()
8633        .flatten()
8634        .and_then(|process| process.observe())
8635}
8636
8637/// Resolve once and spawn that exact path, so PATH lookup cannot choose a
8638/// different executable after the no-delete capture was taken.
8639#[cfg(windows)]
8640fn windows_spawn_capture(spec: &ModuleSpec) -> io::Result<subc_os::ExecutableCapture> {
8641    let mut program = spec.program.clone();
8642    if program.is_absolute() || program.components().count() > 1 {
8643        // An explicit path can name a PE image with no extension. Try that
8644        // exact file first, as Command does, before falling back to .exe.
8645        match subc_os::ExecutableCapture::open(&program) {
8646            Ok(capture) => return Ok(capture),
8647            Err(error)
8648                if error.kind() == io::ErrorKind::NotFound && program.extension().is_none() => {}
8649            Err(error) => return Err(error),
8650        }
8651        program.set_extension("exe");
8652        return subc_os::ExecutableCapture::open(&program);
8653    }
8654    if program.extension().is_none() {
8655        program.set_extension("exe");
8656    }
8657    let custom_path = spec
8658        .env
8659        .iter()
8660        .rev()
8661        .find(|(key, _)| key.eq_ignore_ascii_case("PATH"));
8662    let mut directories = Vec::new();
8663    // These are Windows' default executable search locations. A configured
8664    // PATH still overrides the inherited PATH, not the system directories.
8665    if let Some(parent) = std::env::current_exe()?.parent() {
8666        directories.push(parent.to_path_buf());
8667    }
8668    directories.push(std::env::current_dir()?);
8669    if let Some(windows) = std::env::var_os("SystemRoot") {
8670        let windows = PathBuf::from(windows);
8671        directories.push(windows.join("System32"));
8672        directories.push(windows.clone());
8673    }
8674    let path = custom_path
8675        .map(|(_, value)| std::ffi::OsString::from(value))
8676        .or_else(|| std::env::var_os("PATH"));
8677    if let Some(path) = path {
8678        directories.extend(std::env::split_paths(&path));
8679    }
8680    for directory in directories {
8681        match subc_os::ExecutableCapture::open(&directory.join(&program)) {
8682            Ok(capture) => return Ok(capture),
8683            Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
8684            Err(error) => return Err(error),
8685        }
8686    }
8687    Err(io::Error::new(
8688        io::ErrorKind::NotFound,
8689        "resolved executable not found",
8690    ))
8691}
8692
8693fn spawn_and_mark_running(
8694    spec: &ModuleSpec,
8695    runtime: &SupervisorRuntimeConfig,
8696    snapshot: &SharedSnapshot,
8697) -> Result<SupervisedChild, SuperviseError> {
8698    let activity_spawn = runtime.begin_activity_spawn(&spec.module_id);
8699    let child = spawn_child(
8700        spec,
8701        runtime.connection_file_path.as_deref(),
8702        runtime.supervisor_handle.as_ref(),
8703        &runtime.stderr_ring,
8704        runtime.capture_logs_dir.as_deref(),
8705        &runtime.child_roster,
8706        #[cfg(target_os = "linux")]
8707        runtime.cgroup_placement.as_ref(),
8708    )?;
8709    set_running(snapshot, &child, &spec.module_id, &runtime.spawn_events)?;
8710    if let Some(spawn) = activity_spawn {
8711        spawn.complete(snapshot);
8712    }
8713    Ok(child)
8714}
8715
8716enum RegistrationWaitOutcome {
8717    Registered,
8718    Exited(ExitReport),
8719    TimedOut,
8720}
8721
8722struct ReloadRegistrationFailure {
8723    exit_report: ExitReport,
8724    reason: String,
8725}
8726
8727#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8728enum BusyGaugeObservation {
8729    Quiescent,
8730    Busy,
8731    Omitted,
8732}
8733
8734fn busy_gauge_observation(metrics: Option<&Value>, gauges: &[String]) -> BusyGaugeObservation {
8735    let Some(metrics) = metrics.and_then(Value::as_object) else {
8736        return BusyGaugeObservation::Omitted;
8737    };
8738    let mut sum = 0u128;
8739    for gauge in gauges {
8740        let Some(value) = metrics.get(gauge) else {
8741            return BusyGaugeObservation::Omitted;
8742        };
8743        let Some(value) = value.as_u64() else {
8744            return BusyGaugeObservation::Busy;
8745        };
8746        sum = sum.saturating_add(u128::from(value));
8747    }
8748    if sum == 0 {
8749        BusyGaugeObservation::Quiescent
8750    } else {
8751        BusyGaugeObservation::Busy
8752    }
8753}
8754
8755fn declared_busy_gauges(
8756    registry: &Registry,
8757    module_id: &str,
8758) -> Result<Vec<String>, SuperviseError> {
8759    busy_gauges_of(
8760        registry
8761            .get_module(module_id)
8762            .map_err(SuperviseError::Registry)?,
8763    )
8764}
8765
8766/// [`declared_busy_gauges`] for the registration a connection holds, in any
8767/// slot: after cutover the incumbent is no longer the id's active
8768/// registration, and its own manifest is the one that names its gauges.
8769fn declared_busy_gauges_for_connection(
8770    registry: &Registry,
8771    connection_id: ConnectionId,
8772) -> Result<Vec<String>, SuperviseError> {
8773    busy_gauges_of(
8774        registry
8775            .get_module_by_connection(connection_id)
8776            .map_err(SuperviseError::Registry)?,
8777    )
8778}
8779
8780fn busy_gauges_of(
8781    registration: Option<crate::registry::ModuleRegistration>,
8782) -> Result<Vec<String>, SuperviseError> {
8783    let Some(registration) = registration else {
8784        return Ok(Vec::new());
8785    };
8786    let Some(self_signals) = registration.manifest.self_signals else {
8787        return Ok(Vec::new());
8788    };
8789
8790    let mut gauges = Vec::new();
8791    for declaration in self_signals {
8792        if declaration.kind != SelfSignalKind::Busy {
8793            continue;
8794        }
8795        match declaration.anchored_to {
8796            SignalAnchor::HealthGauges { gauges: declared } if !declared.is_empty() => {
8797                gauges.extend(declared)
8798            }
8799            _ => {
8800                // An invalid Busy anchor is fail-safe: the empty name cannot be
8801                // present in a conforming health report, so this drain stays busy.
8802                gauges.push(String::new());
8803            }
8804        }
8805    }
8806    Ok(gauges)
8807}
8808
8809/// Wait for `endpoint` to have nothing in flight and, when the module declares
8810/// busy gauges, for a health probe to report them quiet. The probe is addressed
8811/// by `scope`: a swap's superseded incumbent must be asked about its own
8812/// gauges, and by module id the probe would reach the promoted candidate.
8813async fn wait_for_forwarding_quiescence(
8814    forwarding: &ForwardingTable,
8815    module_id: &str,
8816    runtime: &SupervisorRuntimeConfig,
8817    endpoint: crate::ModuleEndpointId,
8818    deadline: Instant,
8819    busy_gauges: &[String],
8820    scope: DrainScope,
8821) -> Result<bool, SuperviseError> {
8822    let mut gauges_quiescent = busy_gauges.is_empty();
8823    let mut next_probe_at = Instant::now();
8824    let mut omission_counted = false;
8825
8826    loop {
8827        let now = Instant::now();
8828        if !busy_gauges.is_empty() && now >= next_probe_at && now < deadline {
8829            let report = match scope {
8830                DrainScope::Active => probe_module_health(module_id, runtime, Some(deadline)).await,
8831                DrainScope::Endpoint(endpoint) => {
8832                    probe_endpoint_health(endpoint, runtime, Some(deadline)).await
8833                }
8834            };
8835            gauges_quiescent = match report {
8836                Ok(report) => match busy_gauge_observation(report.metrics.as_ref(), busy_gauges) {
8837                    BusyGaugeObservation::Quiescent => true,
8838                    BusyGaugeObservation::Busy => false,
8839                    BusyGaugeObservation::Omitted => {
8840                        if !omission_counted {
8841                            forwarding
8842                                .counters()
8843                                .increment_drains_with_undeclared_gauge();
8844                            omission_counted = true;
8845                        }
8846                        false
8847                    }
8848                },
8849                Err(err) => {
8850                    warn!(
8851                        module_id,
8852                        error = %err,
8853                        "drain health.check did not produce declared busy gauges; treating module as busy"
8854                    );
8855                    false
8856                }
8857            };
8858            next_probe_at = Instant::now() + runtime.health.cadence.max(REGISTRY_RELEASE_POLL);
8859        }
8860
8861        let in_flight = forwarding
8862            .endpoint_in_flight_count(endpoint)
8863            .map_err(SuperviseError::Forwarding)?;
8864        if in_flight == 0 && gauges_quiescent {
8865            return Ok(true);
8866        }
8867
8868        let now = Instant::now();
8869        if now >= deadline {
8870            return Ok(false);
8871        }
8872        let mut wait = deadline
8873            .saturating_duration_since(now)
8874            .min(REGISTRY_RELEASE_POLL);
8875        if !busy_gauges.is_empty() {
8876            wait = wait.min(next_probe_at.saturating_duration_since(now));
8877        }
8878        sleep(wait).await;
8879    }
8880}
8881
8882/// The `route.closed` `drained` value implied by a quiescence-wait outcome.
8883///
8884/// `Ok` is always honest and passed straight through -- the wait actually measured
8885/// in-flight state. `Err` means the wait produced no measurement at all (the
8886/// forwarding table's lock was poisoned), so `false` is reported as the one honest
8887/// constant: the drain did not complete. Never recomputed from route state, never a
8888/// third "unknown" value -- the caller must still send a well-formed `route.closed`.
8889fn drained_after_quiescence_wait(wait_result: &Result<bool, SuperviseError>) -> bool {
8890    match wait_result {
8891        Ok(drained) => *drained,
8892        Err(_) => false,
8893    }
8894}
8895
8896fn send_route_goodbyes(forwarding: &ForwardingTable, released_routes: Vec<GoodbyeTarget>) {
8897    for released in released_routes {
8898        let frame = match Frame::build_with_version(
8899            released.negotiated_ver,
8900            FrameType::Goodbye,
8901            control_flags(),
8902            released.channel,
8903            released.epoch,
8904            0,
8905            Vec::new(),
8906        ) {
8907            Ok(frame) => frame,
8908            Err(err) => {
8909                warn!(
8910                    route_channel = released.channel,
8911                    error = %err,
8912                    "failed to build supervisor drain route GOODBYE frame"
8913                );
8914                continue;
8915            }
8916        };
8917        if !released.close_on_delivery_failure() {
8918            crate::forwarding::send_module_route_goodbye(
8919                &forwarding.counters(),
8920                &released.sink,
8921                frame,
8922                released.module_id.as_deref(),
8923                "supervisor drain",
8924            );
8925            continue;
8926        }
8927        if let Err(err) = released.sink.try_send(frame) {
8928            warn!(
8929                target_connection_id = released.connection_id.get(),
8930                route_channel = released.channel,
8931                error = %err,
8932                "supervisor drain route GOODBYE was not delivered to client; closing target connection"
8933            );
8934            let _ = forwarding.escalate_client_delivery_failure(
8935                released.connection_id,
8936                released.channel,
8937                released.epoch,
8938                CloseReason::new(
8939                    "route_goodbye_delivery_failed",
8940                    format!(
8941                        "failed to enqueue supervisor drain route GOODBYE for channel {}: {err}",
8942                        released.channel
8943                    ),
8944                ),
8945                crate::forwarding::UndeliveredFrame {
8946                    module_id: released.module_id.as_deref(),
8947                    sink: &released.sink,
8948                },
8949            );
8950        }
8951    }
8952}
8953
8954fn send_module_draining(
8955    module_id: &str,
8956    reason: RouteCloseReason,
8957    deadline_ms: u64,
8958    target: &ModuleDrainTarget,
8959) {
8960    let body = match serde_json::to_vec(&ModuleControlCommand::Draining {
8961        reason,
8962        deadline_ms,
8963    }) {
8964        Ok(body) => body,
8965        Err(err) => {
8966            warn!(
8967                module_id,
8968                error = %err,
8969                "failed to encode module draining command"
8970            );
8971            return;
8972        }
8973    };
8974    let frame = match Frame::build_with_version(
8975        target.negotiated_ver,
8976        FrameType::Push,
8977        control_flags(),
8978        0,
8979        0,
8980        0,
8981        body,
8982    ) {
8983        Ok(frame) => frame,
8984        Err(err) => {
8985            warn!(
8986                module_id,
8987                error = %err,
8988                "failed to build module draining command frame"
8989            );
8990            return;
8991        }
8992    };
8993    if let Err(err) = target.sink.try_send(frame) {
8994        warn!(
8995            module_id,
8996            target_connection_id = target.endpoint.connection_id.get(),
8997            error = %err,
8998            "module draining command was not delivered to peer"
8999        );
9000    }
9001}
9002
9003/// The channel-0 GOODBYE that tells a module its stop is planned.
9004fn module_goodbye_frame(module_id: &str, negotiated_ver: u8) -> Option<Frame> {
9005    match Frame::build_with_version(
9006        negotiated_ver,
9007        FrameType::Goodbye,
9008        control_flags(),
9009        0,
9010        0,
9011        0,
9012        Vec::new(),
9013    ) {
9014        Ok(frame) => Some(frame),
9015        Err(err) => {
9016            warn!(
9017                module_id,
9018                error = %err,
9019                "failed to build module GOODBYE frame"
9020            );
9021            None
9022        }
9023    }
9024}
9025
9026/// Send every registered module connection its module GOODBYE at daemon
9027/// shutdown, then request that connection's close.
9028///
9029/// A module tells a planned stop from a lost daemon by whether a GOODBYE came
9030/// before EOF, so the GOODBYE must reach the socket before the close. A close
9031/// request does not wait for the connection's queued frames: its writer gets a
9032/// bounded grace after the close, is aborted if it overruns it, and the daemon
9033/// process may exit before that grace ends. So with `wait_for_flush`, each
9034/// connection is closed only after its writer has acknowledged writing the
9035/// GOODBYE, or once a short shared budget runs out, so one module that is not
9036/// reading cannot hold up the others or the shutdown. Without it the GOODBYEs
9037/// are only queued, for a shutdown the operator has told to stop waiting.
9038/// A connection that is already gone is skipped.
9039#[cfg(unix)]
9040async fn send_module_goodbyes_for_daemon_shutdown(
9041    forwarding: &Arc<ForwardingTable>,
9042    reason: &CloseReason,
9043    wait_for_flush: bool,
9044) {
9045    const GOODBYE_BUDGET: Duration = Duration::from_millis(500);
9046    let targets = match forwarding.module_connections() {
9047        Ok(targets) => targets,
9048        Err(err) => {
9049            warn!(error = %err, "could not list module connections for shutdown GOODBYE");
9050            return;
9051        }
9052    };
9053    let deadline = Instant::now() + GOODBYE_BUDGET;
9054    let mut sends = tokio::task::JoinSet::new();
9055    for target in targets {
9056        let Some(frame) = module_goodbye_frame(&target.module_id, target.negotiated_ver) else {
9057            continue;
9058        };
9059        if !wait_for_flush {
9060            if let Err(err) = target.sink.try_send(frame) {
9061                debug!(
9062                    module_id = %target.module_id,
9063                    error = %err,
9064                    "shutdown module GOODBYE was not queued"
9065                );
9066            }
9067            continue;
9068        }
9069        let forwarding = Arc::clone(forwarding);
9070        let reason = reason.clone();
9071        sends.spawn(async move {
9072            match timeout_at(deadline, target.sink.send_flushed(frame)).await {
9073                Ok(Ok(())) => {}
9074                Ok(Err(err)) => debug!(
9075                    module_id = %target.module_id,
9076                    error = %err,
9077                    "module connection closed before its shutdown GOODBYE was written"
9078                ),
9079                Err(_) => warn!(
9080                    module_id = %target.module_id,
9081                    budget = ?GOODBYE_BUDGET,
9082                    "shutdown module GOODBYE was not written within its budget; closing anyway"
9083                ),
9084            }
9085            forwarding.request_connection_close(target.endpoint.connection_id, reason);
9086        });
9087    }
9088    // Every task ends by the shared deadline, so this wait is bounded too.
9089    while sends.join_next().await.is_some() {}
9090}
9091
9092fn send_module_goodbye(module_id: &str, forwarding: &ForwardingTable, target: &ModuleDrainTarget) {
9093    let Some(frame) = module_goodbye_frame(module_id, target.negotiated_ver) else {
9094        return;
9095    };
9096    if let Err(err) = target.sink.try_send(frame) {
9097        warn!(
9098            module_id,
9099            target_connection_id = target.endpoint.connection_id.get(),
9100            error = %err,
9101            "supervisor drain module GOODBYE was not delivered to peer; closing module connection"
9102        );
9103        forwarding.request_connection_close(
9104            target.endpoint.connection_id,
9105            CloseReason::new(
9106                "module_goodbye_delivery_failed",
9107                format!("failed to enqueue supervisor drain module GOODBYE for module '{module_id}': {err}"),
9108            ),
9109        );
9110    }
9111}
9112
9113#[derive(Clone, Copy)]
9114struct ForwardingDrainContext<'a> {
9115    spec: &'a ModuleSpec,
9116    runtime: &'a SupervisorRuntimeConfig,
9117    registry: &'a Registry,
9118    scope: DrainScope,
9119}
9120
9121/// Which process a forwarding drain addresses.
9122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9123enum DrainScope {
9124    /// Whatever endpoint is active for the module id: every plain stop,
9125    /// restart and reload. Also moves the module's state to `Draining`.
9126    Active,
9127    /// One specific endpoint: a swap's incumbent after cutover. Draining it by
9128    /// module id would resolve to the promoted candidate and leave neither
9129    /// process routable. The module's state is left alone, since the promoted
9130    /// candidate is what it describes and that process is running.
9131    Endpoint(crate::ModuleEndpointId),
9132}
9133
9134/// Whether a child being drained has already been asked to stop by the time
9135/// its drain wait starts.
9136///
9137/// The drain wait is the same budget whatever this says. What it decides is
9138/// whether the supervisor must ask by signal before that wait begins: a child
9139/// that nobody asked will sit out the whole budget and then be SIGKILLed,
9140/// healthy or not.
9141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9142enum StopNotice {
9143    /// The module was sent `module.draining` and a module GOODBYE over its own
9144    /// registered connection, and stops itself.
9145    SentOverConnection,
9146    /// The forwarding drain found no registered connection for the module: a
9147    /// subc child spawned moments ago that has not sent HELLO yet, or a
9148    /// `protocol: "none"` child, which never registers.
9149    NoConnection,
9150    /// This path sends nothing over the module's connection: the supervisor has
9151    /// no forwarding table, or the caller stops the child without a forwarding
9152    /// drain.
9153    NotSent,
9154}
9155
9156async fn begin_forwarding_drain(
9157    spec: &ModuleSpec,
9158    runtime: &SupervisorRuntimeConfig,
9159    registry: &Registry,
9160    snapshot: &SharedSnapshot,
9161    enabled: Option<bool>,
9162    reason: RouteCloseReason,
9163) -> Result<StopNotice, SuperviseError> {
9164    let Some(forwarding) = runtime.forwarding.as_ref() else {
9165        return Err(SuperviseError::ReloadUnavailable {
9166            module_id: spec.module_id.clone(),
9167            reason: "supervisor was not configured with a forwarding table".to_string(),
9168        });
9169    };
9170
9171    begin_forwarding_drain_with(
9172        forwarding,
9173        ForwardingDrainContext {
9174            spec,
9175            runtime,
9176            registry,
9177            scope: DrainScope::Active,
9178        },
9179        snapshot,
9180        enabled,
9181        reason,
9182        runtime.drain_timeout,
9183    )
9184    .await
9185}
9186
9187async fn begin_forwarding_drain_if_configured(
9188    spec: &ModuleSpec,
9189    runtime: &SupervisorRuntimeConfig,
9190    registry: &Registry,
9191    snapshot: &SharedSnapshot,
9192    enabled: Option<bool>,
9193    reason: RouteCloseReason,
9194) -> Result<StopNotice, SuperviseError> {
9195    begin_forwarding_drain_with_timeout(
9196        spec,
9197        runtime,
9198        registry,
9199        snapshot,
9200        enabled,
9201        reason,
9202        runtime.drain_timeout,
9203    )
9204    .await
9205}
9206
9207/// Like [`begin_forwarding_drain_if_configured`] but with an explicit drain
9208/// budget, for paths where the operator overrides the module's configured one
9209/// (`supervisor.restart{drain_timeout_ms}`).
9210async fn begin_forwarding_drain_with_timeout(
9211    spec: &ModuleSpec,
9212    runtime: &SupervisorRuntimeConfig,
9213    registry: &Registry,
9214    snapshot: &SharedSnapshot,
9215    enabled: Option<bool>,
9216    reason: RouteCloseReason,
9217    drain_timeout: Duration,
9218) -> Result<StopNotice, SuperviseError> {
9219    let Some(forwarding) = runtime.forwarding.as_ref() else {
9220        return Ok(StopNotice::NotSent);
9221    };
9222
9223    begin_forwarding_drain_with(
9224        forwarding,
9225        ForwardingDrainContext {
9226            spec,
9227            runtime,
9228            registry,
9229            scope: DrainScope::Active,
9230        },
9231        snapshot,
9232        enabled,
9233        reason,
9234        drain_timeout,
9235    )
9236    .await
9237}
9238
9239async fn begin_forwarding_drain_with(
9240    forwarding: &ForwardingTable,
9241    context: ForwardingDrainContext<'_>,
9242    snapshot: &SharedSnapshot,
9243    enabled: Option<bool>,
9244    reason: RouteCloseReason,
9245    drain_timeout: Duration,
9246) -> Result<StopNotice, SuperviseError> {
9247    let ForwardingDrainContext {
9248        spec,
9249        runtime,
9250        registry,
9251        scope,
9252    } = context;
9253    debug_assert_ne!(reason, RouteCloseReason::Crash);
9254    let terminal = matches!(reason, RouteCloseReason::Disable);
9255    let drain_started_at = Instant::now();
9256    let drain_deadline = drain_started_at + drain_timeout;
9257    let deadline_ms =
9258        unix_ms_now().saturating_add(u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX));
9259    let busy_gauges = match scope {
9260        DrainScope::Active => declared_busy_gauges(registry, &spec.module_id)?,
9261        DrainScope::Endpoint(endpoint) => {
9262            declared_busy_gauges_for_connection(registry, endpoint.connection_id)?
9263        }
9264    };
9265
9266    // Admission gate first: route.open/commit and route REQUEST admission are closed
9267    // before the first quiescence check, so the outstanding count can only fall.
9268    let gate_started = Instant::now();
9269    let drain_target = match scope {
9270        DrainScope::Active => forwarding.begin_module_drain(&spec.module_id, reason),
9271        DrainScope::Endpoint(endpoint) => forwarding.begin_endpoint_drain(endpoint, reason),
9272    }
9273    .map_err(SuperviseError::Forwarding)?;
9274    // The instant admission closed, and how long taking the forwarding write
9275    // lock to close it took. The timeout line reports only the quiescence
9276    // wait, so without this a drain that started late looked like one that
9277    // started on time.
9278    info!(
9279        module_id = %spec.module_id,
9280        ?reason,
9281        gate_ms = u64::try_from(gate_started.elapsed().as_millis()).unwrap_or(u64::MAX),
9282        connected = drain_target.is_some(),
9283        "module drain began; route admission closed"
9284    );
9285    if scope == DrainScope::Active {
9286        update_snapshot(snapshot, Some(&spec.module_id), |state| {
9287            state.state = ModuleState::Draining;
9288            state.draining_to_replace =
9289                matches!(reason, RouteCloseReason::Restart | RouteCloseReason::Reload);
9290            if let Some(enabled) = enabled {
9291                state.enabled = enabled;
9292            }
9293        })?;
9294    }
9295
9296    let Some(target) = drain_target.as_ref() else {
9297        // Nothing was sent: the module has no registered connection to carry
9298        // `module.draining` or a GOODBYE. The caller must not assume the child
9299        // was asked to stop.
9300        return Ok(StopNotice::NoConnection);
9301    };
9302    {
9303        send_module_draining(&spec.module_id, reason, deadline_ms, target);
9304        let routes = forwarding
9305            .endpoint_routes(target.endpoint)
9306            .map_err(SuperviseError::Forwarding)?;
9307        let routes_notified = routes.len();
9308        crate::control::send_route_control_pushes(
9309            forwarding,
9310            routes.clone(),
9311            ClientControlPush::RouteClosing {
9312                module_id: spec.module_id.clone(),
9313                channels: Vec::new(),
9314                reason,
9315            },
9316        );
9317        send_route_goodbyes(forwarding, target.abandoned_bindings.clone());
9318
9319        // `route.closing` was just sent above: from here on every return path,
9320        // including an early one, MUST send `route.closed` before propagating
9321        // anything else. A client holds `closing` as a promise that a verdict is
9322        // coming; leaving early without `closed` strands it waiting forever, since
9323        // `closing` carries no timeout of its own.
9324        let wait_result = wait_for_forwarding_quiescence(
9325            forwarding,
9326            &spec.module_id,
9327            runtime,
9328            target.endpoint,
9329            drain_deadline,
9330            &busy_gauges,
9331            scope,
9332        )
9333        .await;
9334        let drained = drained_after_quiescence_wait(&wait_result);
9335        if let Err(err) = &wait_result {
9336            error!(
9337                module_id = %spec.module_id,
9338                ?reason,
9339                error = %err,
9340                "forwarding quiescence wait failed after route.closing; forcing route.closed(drained: false) so the client is not left waiting on an unfulfilled promise"
9341            );
9342        } else if !drained {
9343            // Name what the drain waited on. Without it the line says only that
9344            // something did not settle, and "one wedged call" and "every
9345            // session's held stream" read the same; the first is a module bug,
9346            // the second is a module that should end its streams on
9347            // module.draining. Read before teardown releases the routes.
9348            let holdouts = forwarding
9349                .endpoint_drain_holdouts(target.endpoint)
9350                .unwrap_or_default();
9351            warn!(
9352                module_id = %spec.module_id,
9353                waited = ?drain_timeout,
9354                ?reason,
9355                held_requests = holdouts.requests,
9356                held_routes = holdouts.routes,
9357                total_routes = holdouts.total_routes,
9358                top_connections = ?holdouts.top_connections,
9359                // `module_channel:corr`, so the module can find each held request
9360                // in its own log; capped, so `held_requests` is the full count.
9361                held = %holdouts
9362                    .held
9363                    .iter()
9364                    .map(|(channel, corr)| format!("{channel}:{corr}"))
9365                    .collect::<Vec<_>>()
9366                    .join(","),
9367                "route drain timed out before request quiescence; forcing teardown"
9368            );
9369        }
9370        crate::control::send_route_control_pushes(
9371            forwarding,
9372            routes,
9373            ClientControlPush::RouteClosed {
9374                module_id: spec.module_id.clone(),
9375                channels: Vec::new(),
9376                reason,
9377                drained,
9378                abandoned: target.abandoned_bindings.len() as u32,
9379                excluded_subscriptions: target.excluded_subscriptions,
9380                terminal: Some(terminal),
9381            },
9382        );
9383        wait_result?;
9384
9385        // `route.closed` has now been sent unconditionally above. From here the
9386        // remaining steps are cleanup (route + module GOODBYE) rather than a
9387        // promise the client is waiting on, but a lock-poisoned
9388        // `release_module_endpoint_routes` would otherwise skip the module
9389        // GOODBYE silently too -- send it before propagating the error.
9390        let released_routes = match forwarding.release_module_endpoint_routes(target.endpoint) {
9391            Ok(routes) => routes,
9392            Err(err) => {
9393                warn!(
9394                    module_id = %spec.module_id,
9395                    ?reason,
9396                    error = %err,
9397                    "failed to release module endpoint routes after route.closed; module GOODBYE will still be sent"
9398                );
9399                send_module_goodbye(&spec.module_id, forwarding, target);
9400                return Err(SuperviseError::Forwarding(err));
9401            }
9402        };
9403        let route_goodbye_count = released_routes.len();
9404        send_route_goodbyes(forwarding, released_routes);
9405        send_module_goodbye(&spec.module_id, forwarding, target);
9406
9407        // The drain's happy path was previously silent: every emission above is
9408        // best-effort with only its failure arm logged, so "were consumers told"
9409        // was unprovable from the daemon log (surfaced by a 30-minute consumer
9410        // hang where the open question was exactly whether teardown notice went
9411        // out). One summary line makes that class decidable in one grep.
9412        info!(
9413            module_id = %spec.module_id,
9414            ?reason,
9415            routes_notified,
9416            route_goodbyes = route_goodbye_count,
9417            abandoned_reservations = target.abandoned_bindings.len(),
9418            excluded_subscriptions = target.excluded_subscriptions,
9419            drained,
9420            "module drain complete; consumers notified via route.closing/route.closed pushes and per-route GOODBYE frames"
9421        );
9422    }
9423
9424    Ok(StopNotice::SentOverConnection)
9425}
9426
9427/// Wait for the freshly spawned child to take the ACTIVE slot for `module_id`,
9428/// the only slot a plain (non-swap) spawn can register into.
9429async fn wait_for_registration_after_reload(
9430    registry: &Registry,
9431    module_id: &str,
9432    snapshot: &SharedSnapshot,
9433    child: &mut SupervisedChild,
9434    wait: Duration,
9435) -> Result<RegistrationWaitOutcome, SuperviseError> {
9436    wait_for_slot_registration(
9437        registry,
9438        crate::registry::RegistrationSlot::Active(module_id),
9439        module_id,
9440        snapshot,
9441        child,
9442        wait,
9443    )
9444    .await
9445}
9446
9447/// Wait for `child` to register into `slot`, or to exit, or for `wait` to pass.
9448///
9449/// Keyed on the slot rather than the bare module id because during a swap the
9450/// id's active slot is already held by the incumbent: an id-keyed wait would
9451/// report the incumbent's registration as the candidate's and a candidate that
9452/// never registers would look registered. A swap candidate waits on
9453/// `crate::registry::RegistrationSlot::Candidate`.
9454async fn wait_for_slot_registration(
9455    registry: &Registry,
9456    slot: crate::registry::RegistrationSlot<'_>,
9457    module_id: &str,
9458    snapshot: &SharedSnapshot,
9459    child: &mut SupervisedChild,
9460    wait: Duration,
9461) -> Result<RegistrationWaitOutcome, SuperviseError> {
9462    let deadline = Instant::now() + wait;
9463    loop {
9464        if registry
9465            .registration(slot)
9466            .map_err(SuperviseError::Registry)?
9467            .is_some()
9468        {
9469            return Ok(RegistrationWaitOutcome::Registered);
9470        }
9471
9472        let now = Instant::now();
9473        if now >= deadline {
9474            return Ok(RegistrationWaitOutcome::TimedOut);
9475        }
9476        let remaining = deadline.saturating_duration_since(now);
9477        let poll = remaining.min(REGISTRY_RELEASE_POLL);
9478
9479        tokio::select! {
9480            wait_result = child.wait() => {
9481                let status = wait_result.map_err(|source| SuperviseError::Wait {
9482                    module_id: module_id.to_string(),
9483                    source,
9484                })?;
9485                return Ok(RegistrationWaitOutcome::Exited(classify_reaped_child_exit(
9486                    snapshot,
9487                    child,
9488                    &status,
9489                )));
9490            }
9491            _ = sleep(poll) => {}
9492        }
9493    }
9494}
9495
9496fn registration_failure_exit_report(mut exit_report: ExitReport) -> ExitReport {
9497    // A replacement process that exits before HELLO did not provide service, even
9498    // if it used status 0. Count it against the restart cap as a new-binary failure.
9499    if exit_report.kind != ExitKind::DeliberateSeverance {
9500        exit_report.kind = ExitKind::Crash;
9501    }
9502    exit_report
9503}
9504
9505async fn handle_reload_child_registration_failure(
9506    spec: &ModuleSpec,
9507    runtime: &SupervisorRuntimeConfig,
9508    registry: &Registry,
9509    process_liveness: &SupervisorProcessLiveness,
9510    snapshot: &SharedSnapshot,
9511    _child: &mut Option<SupervisedChild>,
9512    failure: ReloadRegistrationFailure,
9513) -> Result<(), SuperviseError> {
9514    let ReloadRegistrationFailure {
9515        exit_report,
9516        reason,
9517    } = failure;
9518    match on_child_exit(
9519        spec,
9520        runtime.restart_policy,
9521        registry,
9522        snapshot,
9523        &runtime.terminal_ring,
9524        &runtime.spawn_events,
9525        &runtime.child_roster,
9526        exit_report,
9527    )
9528    .await
9529    {
9530        NextAction::Stop {
9531            registration_released,
9532        } => {
9533            if registration_released {
9534                process_liveness.untrack_if_current(&spec.module_id, snapshot);
9535            }
9536        }
9537        NextAction::Restart { schedule } => {
9538            let delay = schedule.map_or(runtime.restart_policy.delay_for_restart(0), |schedule| {
9539                schedule.delay
9540            });
9541            if let Some(schedule) = schedule {
9542                log_crash_respawn(&spec.module_id, schedule);
9543            }
9544            schedule_respawn(
9545                runtime,
9546                snapshot,
9547                &spec.module_id,
9548                delay,
9549                RespawnKind::Spawn,
9550            )?;
9551        }
9552    }
9553    Err(SuperviseError::ReloadFailed {
9554        module_id: spec.module_id.clone(),
9555        reason,
9556    })
9557}
9558
9559async fn handle_reload_spawn_failure(
9560    spec: &ModuleSpec,
9561    runtime: &SupervisorRuntimeConfig,
9562    process_liveness: &SupervisorProcessLiveness,
9563    snapshot: &SharedSnapshot,
9564    _child: &mut Option<SupervisedChild>,
9565    reason: String,
9566) -> Result<(), SuperviseError> {
9567    let now = Instant::now();
9568    let mut schedule = None;
9569    update_snapshot(snapshot, Some(&spec.module_id), |state| {
9570        clear_current_process_facts(state);
9571        if state.enabled {
9572            schedule = state.next_crash_restart(&runtime.restart_policy, now);
9573            state.state = if schedule.is_some() {
9574                ModuleState::Restarting
9575            } else {
9576                ModuleState::Failed
9577            };
9578        } else {
9579            state.state = ModuleState::Disabled;
9580        }
9581    })?;
9582    if let Some(schedule) = schedule {
9583        schedule_respawn(
9584            runtime,
9585            snapshot,
9586            &spec.module_id,
9587            schedule.delay,
9588            RespawnKind::Spawn,
9589        )?;
9590    } else {
9591        process_liveness.untrack_if_current(&spec.module_id, snapshot);
9592    }
9593    Err(SuperviseError::ReloadFailed {
9594        module_id: spec.module_id.clone(),
9595        reason,
9596    })
9597}
9598
9599fn control_flags() -> Flags {
9600    Flags::new(false, Priority::Passive, false)
9601}
9602
9603#[allow(clippy::too_many_arguments)]
9604async fn drain_optional_child(
9605    module_id: &str,
9606    protocol: ModuleProtocol,
9607    stop_notice: StopNotice,
9608    registry: &Registry,
9609    forwarding: Option<&ForwardingTable>,
9610    snapshot: &SharedSnapshot,
9611    terminal_ring: &Arc<Mutex<TerminalRing>>,
9612    spawn_events: &SpawnEventFeed,
9613    child: &mut Option<SupervisedChild>,
9614    drain_timeout: Duration,
9615    final_state: ModuleState,
9616    enabled: Option<bool>,
9617) -> Result<(), SuperviseError> {
9618    if let Some(child) = child.take() {
9619        drain_child_to_state(
9620            module_id,
9621            protocol,
9622            stop_notice,
9623            registry,
9624            forwarding,
9625            snapshot,
9626            terminal_ring,
9627            spawn_events,
9628            child,
9629            drain_timeout,
9630            final_state,
9631            enabled,
9632        )
9633        .await
9634    } else {
9635        update_snapshot(snapshot, Some(module_id), |state| {
9636            state.state = final_state;
9637            if let Some(enabled) = enabled {
9638                state.enabled = enabled;
9639            }
9640            clear_current_process_facts(state);
9641        })?;
9642        release_dead_registration(registry, forwarding, snapshot, module_id).await
9643    }
9644}
9645
9646#[allow(clippy::too_many_arguments)]
9647async fn drain_child_to_state(
9648    module_id: &str,
9649    _protocol: ModuleProtocol,
9650    stop_notice: StopNotice,
9651    registry: &Registry,
9652    forwarding: Option<&ForwardingTable>,
9653    snapshot: &SharedSnapshot,
9654    terminal_ring: &Arc<Mutex<TerminalRing>>,
9655    spawn_events: &SpawnEventFeed,
9656    mut child: SupervisedChild,
9657    drain_timeout: Duration,
9658    final_state: ModuleState,
9659    enabled: Option<bool>,
9660) -> Result<(), SuperviseError> {
9661    let protocol = child.protocol;
9662    update_snapshot(snapshot, Some(module_id), |state| {
9663        state.state = ModuleState::Draining;
9664        state.draining_to_replace = final_state == ModuleState::Restarting;
9665        if let Some(enabled) = enabled {
9666            state.enabled = enabled;
9667        }
9668    })?;
9669
9670    // The wait below is the same budget in every case; what differs is
9671    // whether anything has ASKED the child to stop before it starts. Only a
9672    // forwarding drain that reached the module's registered connection has
9673    // (`module.draining`, then a module GOODBYE). Every other child was told
9674    // nothing: a `protocol: "none"` module, which never registers; a subc
9675    // module spawned moments ago that has not sent HELLO yet; or a stop that
9676    // runs no forwarding drain. Without a signal the budget is only a delay
9677    // in front of SIGKILL -- and the not-yet-registered child is the worst
9678    // case, because it registers into a module that is already draining,
9679    // is never told, and is killed while healthy.
9680    if stop_notice != StopNotice::SentOverConnection {
9681        if protocol == ModuleProtocol::Subc && stop_notice == StopNotice::NoConnection {
9682            info!(
9683                module_id,
9684                pid = child.pid,
9685                budget_ms = u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX),
9686                "module has no connection yet; requesting stop by signal"
9687            );
9688        }
9689        request_graceful_stop(module_id, &child);
9690    }
9691
9692    let exit_report = match timeout(drain_timeout, child.wait()).await {
9693        Ok(Ok(status)) => classify_reaped_child_exit(snapshot, &child, &status),
9694        Ok(Err(source)) => {
9695            fail_after_wait_error(
9696                module_id,
9697                snapshot,
9698                terminal_ring,
9699                spawn_events,
9700                format!("failed to wait during drain: {source}"),
9701            );
9702            return Err(SuperviseError::Wait {
9703                module_id: module_id.to_string(),
9704                source,
9705            });
9706        }
9707        Err(_) => {
9708            // Mirror the sibling arm above: state is already `Draining`, and an
9709            // error propagated from here would strand it there -- a state
9710            // `set_enabled(true)` cannot heal (`revive_terminal` matches only
9711            // `Failed | Stopped`), leaving an operator Restart as the only exit.
9712            // `Failed` before `?` keeps the module operator-visible and
9713            // revivable. Trigger is an ESRCH race (process exits between the
9714            // drain timeout firing and the kill) or a post-kill wait failure
9715            // (issue #34).
9716            //
9717            // Logged because the kill is otherwise visible only as signal 9 in
9718            // the terminal ring, and the budget it follows can be long enough
9719            // that consumers see a stretch of refusals with no stated cause.
9720            warn!(
9721                module_id,
9722                pid = child.pid,
9723                budget_ms = u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX),
9724                reason = ?final_state,
9725                ?stop_notice,
9726                "drain budget expired before the module exited; killing it"
9727            );
9728            child.start_kill().map_err(|source| {
9729                fail_after_wait_error(
9730                    module_id,
9731                    snapshot,
9732                    terminal_ring,
9733                    spawn_events,
9734                    format!("failed to kill during drain: {source}"),
9735                );
9736                SuperviseError::Kill {
9737                    module_id: module_id.to_string(),
9738                    source,
9739                }
9740            })?;
9741            let status = child.wait().await.map_err(|source| {
9742                fail_after_wait_error(
9743                    module_id,
9744                    snapshot,
9745                    terminal_ring,
9746                    spawn_events,
9747                    format!("failed to wait after drain kill: {source}"),
9748                );
9749                SuperviseError::Wait {
9750                    module_id: module_id.to_string(),
9751                    source,
9752                }
9753            })?;
9754            classify_reaped_child_exit(snapshot, &child, &status)
9755        }
9756    };
9757
9758    let detail = lock_snapshot(snapshot)?.drain_disposition_detail.take();
9759    publish_after_terminal_record(
9760        module_id,
9761        || {
9762            record_terminal_with_detail(
9763                module_id,
9764                terminal_ring,
9765                spawn_events,
9766                &exit_report,
9767                terminal_disposition(final_state),
9768                detail,
9769            )
9770        },
9771        || {
9772            update_snapshot(snapshot, Some(module_id), |state| {
9773                state.state = final_state;
9774                if let Some(enabled) = enabled {
9775                    state.enabled = enabled;
9776                }
9777                clear_current_process_facts(state);
9778                state.last_exit = Some(exit_report.clone());
9779                if exit_report.kind == ExitKind::DeliberateSeverance {
9780                    state.lifetime_restarts += 1;
9781                }
9782            })
9783        },
9784    )?;
9785    child.drain_stderr(module_id).await;
9786
9787    release_dead_registration(registry, forwarding, snapshot, module_id).await
9788}
9789
9790/// Ask a child that nothing else has asked to stop, by signal.
9791///
9792/// A registered subc module is asked over its own connection: the drain sends
9793/// `route.closing`/`route.closed` to its consumers, a GOODBYE per route, then a
9794/// module GOODBYE, and the module stops itself. A module that speaks no subc
9795/// wire receives none of that, and neither does a subc module that has not
9796/// registered yet, so for them the drain budget would be pure delay in front of
9797/// a SIGKILL -- and for a process with a store to flush (JetStream is the
9798/// reason `protocol: "none"` exists) a SIGKILL turns every ordinary teardown
9799/// into a recovery on the next start.
9800///
9801/// NEVER CALLED FOR A MODULE THAT WAS TOLD OVER ITS CONNECTION, and that is a
9802/// rule rather than an optimisation: that module's graceful stop is already
9803/// running by the time its child is drained, and a signal would race it.
9804///
9805/// Best-effort by construction. A child that has already exited is the ordinary
9806/// case rather than an error (the kill lands on a reaped or exiting pid), so a
9807/// failure is logged at debug and the wait-then-kill below still decides the
9808/// outcome.
9809#[cfg(unix)]
9810fn request_graceful_stop(module_id: &str, child: &SupervisedChild) {
9811    let Some(pid) = child
9812        .id()
9813        .and_then(|pid| i32::try_from(pid).ok())
9814        .and_then(rustix::process::Pid::from_raw)
9815    else {
9816        debug!(
9817            module_id,
9818            "no pid to signal for teardown; falling through to the drain wait"
9819        );
9820        return;
9821    };
9822    match rustix::process::kill_process(pid, rustix::process::Signal::TERM) {
9823        Ok(()) => debug!(
9824            module_id,
9825            "sent SIGTERM to a module nothing else asked to stop"
9826        ),
9827        Err(err) => debug!(
9828            module_id,
9829            error = %err,
9830            "SIGTERM to module failed; the drain wait and kill still apply"
9831        ),
9832    }
9833}
9834
9835/// Windows has no SIGTERM and no portable stand-in for one. The graceful stops
9836/// Windows does offer need cooperation this supervisor cannot assume: a console
9837/// control event requires sharing a console with the child, and `WM_CLOSE`
9838/// requires the child to pump a message loop. A supervised server process does
9839/// neither, so there is nothing to send and teardown is the wait followed by the
9840/// kill. Emulating a signal here would mean inventing a stop protocol, which is
9841/// the thing `protocol: "none"` exists to avoid.
9842#[cfg(not(unix))]
9843fn request_graceful_stop(module_id: &str, _child: &SupervisedChild) {
9844    debug!(
9845        module_id,
9846        "no graceful stop signal exists on this platform; teardown of a module nothing asked to stop waits, then kills"
9847    );
9848}
9849
9850fn terminal_disposition(final_state: ModuleState) -> TerminalDisposition {
9851    match final_state {
9852        ModuleState::Stopped => TerminalDisposition::Stopped,
9853        ModuleState::Disabled => TerminalDisposition::Disabled,
9854        ModuleState::Restarting => TerminalDisposition::Restarting,
9855        ModuleState::Failed => TerminalDisposition::Failed,
9856        ModuleState::Starting
9857        | ModuleState::Running
9858        | ModuleState::Unresponsive
9859        | ModuleState::Draining => {
9860            unreachable!("terminal exits only finish in terminal or restarting states")
9861        }
9862    }
9863}
9864
9865/// Release a reaped child's registration before allowing another spawn.
9866///
9867/// EOF is not a process-lifetime signal: an inherited socket can stay open
9868/// indefinitely, and serial frame dispatch can be waiting on egress instead of
9869/// reading EOF. After the normal release grace, request connection close (which
9870/// cancels both reads and dispatch), then allow one more release grace for the
9871/// connection guard's forwarding cleanup. Never evict a different connection.
9872async fn release_dead_registration(
9873    registry: &Registry,
9874    forwarding: Option<&ForwardingTable>,
9875    snapshot: &SharedSnapshot,
9876    module_id: &str,
9877) -> Result<(), SuperviseError> {
9878    let result = async {
9879        let registration = registry
9880            .get_module(module_id)
9881            .map_err(SuperviseError::Registry)?;
9882        match wait_for_registration_release(registry, module_id, REGISTRY_RELEASE_TIMEOUT).await {
9883            Ok(()) => return Ok(()),
9884            Err(SuperviseError::RegistrationStillActive { .. }) => {}
9885            Err(err) => return Err(err),
9886        }
9887        let pid = lock_snapshot(snapshot)?.reaped_pid;
9888        if let (Some(registration), Some(forwarding), Some(pid)) = (registration, forwarding, pid) {
9889            warn!(
9890                module_id,
9891                pid,
9892                connection_id = registration.connection_id.get(),
9893                "reaped module registration outlived release grace; closing dead connection"
9894            );
9895            forwarding.request_connection_close(
9896                registration.connection_id,
9897                CloseReason::new(
9898                    "supervised_process_reaped",
9899                    format!("module '{module_id}' pid {pid} exited"),
9900                ),
9901            );
9902            wait_for_slot_registration_release(
9903                registry,
9904                crate::registry::RegistrationSlot::Connection(registration.connection_id),
9905                REGISTRY_RELEASE_TIMEOUT,
9906            )
9907            .await?;
9908        }
9909        wait_for_registration_release(registry, module_id, Duration::ZERO).await
9910    }
9911    .await;
9912    if let Err(err) = &result {
9913        fail_snapshot(snapshot, Some(module_id), None);
9914        error!(module_id, error = %err, "registration release failed after child exit; module is failed and start can retry");
9915    }
9916    result
9917}
9918
9919/// Wait for the ACTIVE registration of `module_id` to go away, which is what a
9920/// plain stop or restart waits for before it spawns a replacement.
9921async fn wait_for_registration_release(
9922    registry: &Registry,
9923    module_id: &str,
9924    wait: Duration,
9925) -> Result<(), SuperviseError> {
9926    wait_for_slot_registration_release(
9927        registry,
9928        crate::registry::RegistrationSlot::Active(module_id),
9929        wait,
9930    )
9931    .await
9932}
9933
9934/// Wait for the registration in `slot` to go away.
9935///
9936/// Keyed on the slot rather than the bare module id because a successful swap
9937/// never empties the id's active slot (the promoted candidate is in it), so an
9938/// id-keyed wait for the incumbent's release would always time out. Draining a
9939/// swap's incumbent waits on `crate::registry::RegistrationSlot::Connection` with the
9940/// incumbent's connection instead.
9941async fn wait_for_slot_registration_release(
9942    registry: &Registry,
9943    slot: crate::registry::RegistrationSlot<'_>,
9944    wait: Duration,
9945) -> Result<(), SuperviseError> {
9946    let deadline = Instant::now() + wait;
9947    let mut release_events = registration_release_events().subscribe();
9948    let still_active = |registration: &crate::registry::ModuleRegistration| {
9949        SuperviseError::RegistrationStillActive {
9950            module_id: registration.manifest.module_id.clone(),
9951            waited: wait,
9952        }
9953    };
9954    loop {
9955        let _observed_generation = *release_events.borrow_and_update();
9956        let Some(registration) = registry
9957            .registration(slot)
9958            .map_err(SuperviseError::Registry)?
9959        else {
9960            return Ok(());
9961        };
9962
9963        let now = Instant::now();
9964        if now >= deadline {
9965            return Err(still_active(&registration));
9966        }
9967
9968        let remaining = deadline.saturating_duration_since(now);
9969        match timeout(remaining, release_events.changed()).await {
9970            Ok(Ok(())) | Ok(Err(_)) => {}
9971            Err(_) => return Err(still_active(&registration)),
9972        }
9973    }
9974}
9975
9976#[cfg(test)]
9977mod slot_registration_wait_tests {
9978    use super::*;
9979    use crate::registry::{ConnectionId, RegistrationSlot};
9980    use subc_protocol::manifest::ModuleManifest;
9981
9982    #[tokio::test]
9983    async fn enable_release_failure_is_failed_and_a_second_enable_retries() {
9984        let registry = Arc::new(Registry::default());
9985        let supervisor = Supervisor::new_for_test(Arc::clone(&registry), RestartPolicy::default());
9986        let runtime = supervisor.runtime_config();
9987        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::disabled()));
9988        let spec = ModuleSpec {
9989            module_id: "enable-stale-registration".to_string(),
9990            program: PathBuf::from("/missing/enable-retry-test"),
9991            args: Vec::new(),
9992            env: Vec::new(),
9993            reserved: false,
9994            reserved_prefixes: Vec::new(),
9995            protocol: ModuleProtocol::Subc,
9996            overlap: Default::default(),
9997        };
9998        let connection = ConnectionId::new(90);
9999        registry
10000            .register_with_control_ops(
10001                ModuleManifest::builder(&spec.module_id, "0.1.0").build(),
10002                1,
10003                connection,
10004                Vec::new(),
10005            )
10006            .unwrap();
10007        let mut child = None;
10008        let err = set_child_enabled(
10009            &spec,
10010            &runtime,
10011            &registry,
10012            &supervisor.process_liveness,
10013            &snapshot,
10014            &mut child,
10015            true,
10016        )
10017        .await
10018        .unwrap_err();
10019        assert!(matches!(
10020            err,
10021            SuperviseError::RegistrationStillActive { .. }
10022        ));
10023        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
10024        assert!(child.is_none());
10025        registry.deregister_connection(connection).unwrap();
10026        let err = set_child_enabled(
10027            &spec,
10028            &runtime,
10029            &registry,
10030            &supervisor.process_liveness,
10031            &snapshot,
10032            &mut child,
10033            true,
10034        )
10035        .await
10036        .unwrap_err();
10037        assert!(
10038            matches!(err, SuperviseError::Spawn { .. }),
10039            "second enable must attempt a spawn: {err}"
10040        );
10041        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
10042    }
10043
10044    const INCUMBENT: u64 = 1;
10045    const CANDIDATE: u64 = 2;
10046
10047    fn swapped_registry() -> Arc<Registry> {
10048        let registry = Arc::new(Registry::default());
10049        let manifest = ModuleManifest::builder("m", "0.1.0").build();
10050        registry
10051            .register_with_control_ops(
10052                manifest.clone(),
10053                1,
10054                ConnectionId::new(INCUMBENT),
10055                Vec::new(),
10056            )
10057            .unwrap();
10058        registry
10059            .register_candidate_with_control_ops(
10060                manifest,
10061                1,
10062                ConnectionId::new(CANDIDATE),
10063                Vec::new(),
10064            )
10065            .unwrap();
10066        registry
10067    }
10068
10069    /// After a promotion the id's active slot is held by the new process, so an
10070    /// id-keyed wait for the incumbent's release can never succeed; the
10071    /// connection-keyed wait completes as soon as the incumbent deregisters.
10072    #[tokio::test]
10073    async fn incumbent_release_is_awaited_by_connection_not_by_module_id() {
10074        let registry = swapped_registry();
10075        registry.promote_candidate("m").unwrap().unwrap();
10076
10077        assert!(matches!(
10078            wait_for_registration_release(&registry, "m", Duration::from_millis(50)).await,
10079            Err(SuperviseError::RegistrationStillActive { .. })
10080        ));
10081
10082        // Still held while the incumbent's connection has not deregistered.
10083        assert!(matches!(
10084            wait_for_slot_registration_release(
10085                &registry,
10086                RegistrationSlot::Connection(ConnectionId::new(INCUMBENT)),
10087                Duration::from_millis(50),
10088            )
10089            .await,
10090            Err(SuperviseError::RegistrationStillActive { .. })
10091        ));
10092
10093        let releaser = Arc::clone(&registry);
10094        let release = tokio::spawn(async move {
10095            sleep(Duration::from_millis(20)).await;
10096            releaser
10097                .deregister_connection(ConnectionId::new(INCUMBENT))
10098                .unwrap();
10099            notify_registration_release();
10100        });
10101        wait_for_slot_registration_release(
10102            &registry,
10103            RegistrationSlot::Connection(ConnectionId::new(INCUMBENT)),
10104            Duration::from_secs(5),
10105        )
10106        .await
10107        .expect("the incumbent's own registration is released");
10108        release.await.unwrap();
10109        assert!(registry.get_module("m").unwrap().is_some());
10110    }
10111
10112    /// The candidate slot is waited on separately from the active slot: the
10113    /// incumbent's registration neither holds up nor stands in for it.
10114    #[tokio::test]
10115    async fn candidate_slot_wait_ignores_the_incumbents_registration() {
10116        let registry = swapped_registry();
10117        assert!(matches!(
10118            wait_for_slot_registration_release(
10119                &registry,
10120                RegistrationSlot::Candidate("m"),
10121                Duration::from_millis(50),
10122            )
10123            .await,
10124            Err(SuperviseError::RegistrationStillActive { .. })
10125        ));
10126        registry
10127            .deregister_connection(ConnectionId::new(CANDIDATE))
10128            .unwrap();
10129        wait_for_slot_registration_release(
10130            &registry,
10131            RegistrationSlot::Candidate("m"),
10132            Duration::from_millis(50),
10133        )
10134        .await
10135        .expect("a candidate slot with no candidate is released");
10136        assert!(registry
10137            .registration(RegistrationSlot::Active("m"))
10138            .unwrap()
10139            .is_some());
10140    }
10141}
10142
10143fn classify_exit(status: &ExitStatus) -> ExitReport {
10144    ExitReport {
10145        kind: if status.success() {
10146            ExitKind::Clean
10147        } else {
10148            ExitKind::Crash
10149        },
10150        code: status.code(),
10151        signal: exit_signal(status),
10152        at_ms: unix_ms_now(),
10153    }
10154}
10155
10156/// The terminal record for a module whose `wait()` call itself errored (e.g. the
10157/// child was already reaped out-of-band). There is no `ExitStatus` to read a code
10158/// or signal from -- `None`/`None` is the honest shape, not a guess -- but the
10159/// disposition still must be `Failed` so the terminal ring is not silently missing
10160/// an entry, matching what `fail_snapshot` records for this same arm.
10161fn wait_error_exit_report() -> ExitReport {
10162    ExitReport {
10163        kind: ExitKind::Crash,
10164        code: None,
10165        signal: None,
10166        at_ms: unix_ms_now(),
10167    }
10168}
10169
10170#[cfg(unix)]
10171fn exit_signal(status: &ExitStatus) -> Option<i32> {
10172    use std::os::unix::process::ExitStatusExt;
10173
10174    status.signal()
10175}
10176
10177#[cfg(not(unix))]
10178fn exit_signal(_status: &ExitStatus) -> Option<i32> {
10179    None
10180}
10181
10182/// Give an operator-touched module its full crash budget back.
10183///
10184/// Named for the counter it used to zero; it now empties the in-window ring,
10185/// which is the same act. `lifetime_restarts` is untouched on purpose -- the
10186/// ledger of what happened survives every operator action.
10187fn reset_restart_count(snapshot: &SharedSnapshot, module_id: &str) -> Result<(), SuperviseError> {
10188    update_snapshot(snapshot, Some(module_id), |state| {
10189        state.clear_crash_restarts();
10190    })
10191}
10192
10193fn set_running(
10194    snapshot: &SharedSnapshot,
10195    child: &SupervisedChild,
10196    module_id: &str,
10197    spawn_events: &SpawnEventFeed,
10198) -> Result<(), SuperviseError> {
10199    let mut state = snapshot.lock().map_err(|_| SuperviseError::StatePoisoned {
10200        module_id: Some(module_id.to_string()),
10201    })?;
10202    state.spawn_generation = spawn_events.emit_spawned(module_id, child.pid, child.spawned_at_ms);
10203    if std::mem::take(&mut state.coalesced_restart_pending) {
10204        let generation = state.spawn_generation;
10205        info!(module_id, "restart already satisfied by generation {generation}; coalesced pending request completed");
10206    }
10207    state.drain_disposition_detail = None;
10208    state.spawn_failure = None;
10209    state.waiting_for_previous_pid = None;
10210    // Every caller of this is a plain spawn, which always uses the primary key;
10211    // a promoted swap candidate sets the flag itself after this returns.
10212    state.in_alternate_slot = false;
10213    state.configuration_updated_since_spawn = false;
10214    state.spawned_protocol = Some(child.protocol);
10215    state.state = ModuleState::Running;
10216    state.enabled = true;
10217    state.process_alive = true;
10218    state.pid = child.id();
10219    #[cfg(windows)]
10220    {
10221        state.pipe_consumed = child
10222            .nonce_delivery
10223            .as_ref()
10224            .map(|delivery| delivery.consumed());
10225    }
10226    #[cfg(target_os = "macos")]
10227    {
10228        state.report_ready = Some(Arc::clone(&child.report_ready));
10229    }
10230    state.spawned_at_ms = Some(child.spawned_at_ms);
10231    state.spawned_from = Some(child.spawned_from.clone());
10232    state.spawned_file_identity = child.spawned_file_identity.clone();
10233    state.process_start_time = child.process_start_time;
10234    Ok(())
10235}
10236
10237fn clear_current_process_facts(state: &mut SupervisorSnapshot) {
10238    state.waiting_for_previous_pid = None;
10239    state.process_alive = false;
10240    state.spawned_protocol = None;
10241    state.pid = None;
10242    #[cfg(windows)]
10243    {
10244        state.pipe_consumed = None;
10245    }
10246    #[cfg(target_os = "macos")]
10247    {
10248        state.report_ready = None;
10249    }
10250    state.spawned_at_ms = None;
10251    state.spawned_from = None;
10252    state.spawned_file_identity = None;
10253    state.process_start_time = None;
10254    state.deliberate_severance = None;
10255}
10256
10257#[cfg(test)]
10258fn record_deliberate_severance(
10259    snapshot: &SharedSnapshot,
10260    identity: ProcessIdentity,
10261) -> Result<(), SuperviseError> {
10262    update_snapshot(snapshot, None, |state| {
10263        state.deliberate_severance = Some(identity);
10264    })
10265}
10266
10267fn apply_deliberate_severance_marker(
10268    snapshot: &SharedSnapshot,
10269    exited_identity: Option<ProcessIdentity>,
10270    mut exit_report: ExitReport,
10271) -> ExitReport {
10272    let marker = lock_snapshot(snapshot)
10273        .ok()
10274        .and_then(|mut state| state.deliberate_severance.take());
10275    if marker.is_some() && marker == exited_identity {
10276        exit_report.kind = ExitKind::DeliberateSeverance;
10277    }
10278    exit_report
10279}
10280
10281fn classify_reaped_child_exit(
10282    snapshot: &SharedSnapshot,
10283    child: &SupervisedChild,
10284    status: &ExitStatus,
10285) -> ExitReport {
10286    let _ = update_snapshot(snapshot, None, |state| {
10287        state.reaped_pid = Some(child.pid);
10288        state.spawn_failure = child.spawn_failure.clone();
10289    });
10290    apply_deliberate_severance_marker(snapshot, child.process_identity(), classify_exit(status))
10291}
10292
10293fn fail_snapshot(
10294    snapshot: &SharedSnapshot,
10295    module_id: Option<&str>,
10296    last_exit: Option<ExitReport>,
10297) {
10298    if let Err(err) = update_snapshot(snapshot, module_id, |state| {
10299        state.state = ModuleState::Failed;
10300        clear_current_process_facts(state);
10301        if let Some(last_exit) = last_exit {
10302            state.last_exit = Some(last_exit);
10303        }
10304    }) {
10305        error!(error = %err, "failed to mark supervisor state failed");
10306    }
10307}
10308
10309fn update_snapshot(
10310    snapshot: &SharedSnapshot,
10311    module_id: Option<&str>,
10312    update: impl FnOnce(&mut SupervisorSnapshot),
10313) -> Result<(), SuperviseError> {
10314    let mut state = snapshot.lock().map_err(|_| SuperviseError::StatePoisoned {
10315        module_id: module_id.map(ToOwned::to_owned),
10316    })?;
10317    update(&mut state);
10318    Ok(())
10319}
10320
10321const SLOW_SNAPSHOT_LOCK_THRESHOLD: Duration = Duration::from_millis(250);
10322
10323fn lock_snapshot_for_control<'a>(
10324    snapshot: &'a SharedSnapshot,
10325    module_id: &str,
10326    caller: &'static str,
10327) -> Result<std::sync::MutexGuard<'a, SupervisorSnapshot>, SuperviseError> {
10328    let started_at = Instant::now();
10329    let guard = lock_snapshot(snapshot)?;
10330    let waited = started_at.elapsed();
10331    if waited >= SLOW_SNAPSHOT_LOCK_THRESHOLD {
10332        warn!(
10333            module_id = %module_id,
10334            waited_ms = waited.as_millis() as u64,
10335            caller = %caller,
10336            "slow snapshot lock"
10337        );
10338    }
10339    Ok(guard)
10340}
10341
10342fn lock_snapshot(
10343    snapshot: &SharedSnapshot,
10344) -> Result<std::sync::MutexGuard<'_, SupervisorSnapshot>, SuperviseError> {
10345    snapshot
10346        .lock()
10347        .map_err(|_| SuperviseError::StatePoisoned { module_id: None })
10348}
10349
10350#[cfg(test)]
10351mod terminal_history_tests {
10352    use std::{
10353        path::PathBuf,
10354        sync::Arc,
10355        time::{Duration, Instant},
10356    };
10357
10358    use tokio::{sync::mpsc, time::sleep};
10359
10360    use super::{
10361        apply_deliberate_severance_marker, daemon_will_restart, drain_child_to_state,
10362        drained_after_quiescence_wait, handle_reload_spawn_failure, health_restart_child,
10363        lock_snapshot, on_child_exit, record_deliberate_severance, record_wait_error_terminal,
10364        reset_restart_count, spawn_and_mark_running, update_snapshot, wait_error_exit_report,
10365        ExitKind, ExitReport, ModuleProtocol, ModuleSpec, ModuleState, NextAction, ProcessIdentity,
10366        RestartPolicy, SpawnEventKind, StopNotice, SuperviseError, SupervisedConfiguration,
10367        SupervisedModule, SupervisedModuleInner, Supervisor, SupervisorHandle,
10368        SupervisorHealthStatus, SupervisorSnapshot,
10369    };
10370    // The supervisor's clock, distinct from the `std::time::Instant` these tests
10371    // use for their own wall-clock deadlines: crash-restart instants must be on
10372    // the same clock the production code stamps them with, which is tokio's (and
10373    // is what `start_paused` tests can move).
10374    use super::Instant as ClockInstant;
10375    use crate::{
10376        registry::Registry,
10377        terminal_ring::{TerminalRing, TerminalRingConfig},
10378    };
10379    use std::sync::Mutex;
10380    use subc_control::TerminalDisposition;
10381
10382    /// See the twin in `control.rs` for why this derives the path from
10383    /// `current_exe()` and why the existence check is here: `--lib` alone does
10384    /// not build `[[bin]]` targets, and a bare spawn then fails with a raw
10385    /// `NotFound` that reads as a broken test rather than an unbuilt dependency.
10386    pub(super) fn fake_aft_stub_path() -> PathBuf {
10387        let mut path = std::env::current_exe().expect("current_exe available in tests");
10388        path.pop();
10389        path.pop();
10390        path.push(if cfg!(windows) {
10391            "fake-aft-stub.exe"
10392        } else {
10393            "fake-aft-stub"
10394        });
10395        assert!(
10396            path.exists(),
10397            "fake-aft-stub not built at {}: run `cargo test -p subc-core` (which builds \
10398             [[bin]] targets) rather than `cargo test -p subc-core --lib` (which does not)",
10399            path.display()
10400        );
10401        path
10402    }
10403
10404    #[test]
10405    fn reserved_never_spawned_refuses_every_hello() {
10406        // The canary hole: a reserved id whose module has never spawned had NO
10407        // gate entry and admitted anyone -- the reservation protected the nonce
10408        // holder, not the NAME. Now the entry is present with no legitimate
10409        // holder and refuses all comers.
10410        let supervisor = SupervisorHandle::default();
10411        supervisor.apply_identity_configuration(&ModuleSpec {
10412            module_id: "never-spawned".to_string(),
10413            program: PathBuf::from("/usr/bin/false"),
10414            args: Vec::new(),
10415            env: Vec::new(),
10416            reserved: true,
10417            reserved_prefixes: Vec::new(),
10418            protocol: ModuleProtocol::Subc,
10419            overlap: Default::default(),
10420        });
10421        assert!(
10422            supervisor
10423                .reserved_hello_rejection("never-spawned", Some("any-forged-nonce"))
10424                .is_some(),
10425            "forged nonce must refuse on a reserved never-spawned id"
10426        );
10427        assert!(
10428            supervisor
10429                .reserved_hello_rejection("never-spawned", None)
10430                .is_some(),
10431            "absent nonce must refuse on a reserved never-spawned id"
10432        );
10433        // And a real spawn nonce minted later admits exactly that nonce.
10434        supervisor.set_spawn_nonce("never-spawned", "minted".to_string());
10435        supervisor.apply_identity_configuration(&ModuleSpec {
10436            module_id: "never-spawned".to_string(),
10437            program: PathBuf::from("/usr/bin/false"),
10438            args: Vec::new(),
10439            env: Vec::new(),
10440            reserved: true,
10441            reserved_prefixes: Vec::new(),
10442            protocol: ModuleProtocol::Subc,
10443            overlap: Default::default(),
10444        });
10445        assert!(supervisor
10446            .reserved_hello_rejection("never-spawned", Some("minted"))
10447            .is_none());
10448        assert!(supervisor
10449            .reserved_hello_rejection("never-spawned", Some("forged"))
10450            .is_some());
10451    }
10452
10453    /// Put `count` crash restarts on a snapshot's ring as if they had all just
10454    /// happened, which is what "spent budget" looks like to every reader.
10455    fn seed_crash_restarts(state: &mut SupervisorSnapshot, count: u32) {
10456        let now = ClockInstant::now();
10457        for _ in 0..count {
10458            state.crash_restarts.push_back(now);
10459        }
10460    }
10461
10462    /// Age the oldest recorded restart out of `window`, standing in for the hours
10463    /// that would otherwise have to pass. Injecting the instant is the point: a
10464    /// test that slept a real window would take ten minutes and still prove less.
10465    fn age_oldest_crash_restart_out_of_window(state: &mut SupervisorSnapshot, window: Duration) {
10466        let aged = state
10467            .crash_restarts
10468            .front()
10469            .expect("a crash restart must be recorded before it can be aged")
10470            .checked_sub(window + Duration::from_secs(1))
10471            .expect("the test clock is far enough from its origin to age an instant");
10472        state.crash_restarts[0] = aged;
10473    }
10474
10475    fn snapshot_with_restarts(enabled: bool, count: u32) -> SupervisorSnapshot {
10476        let mut state = SupervisorSnapshot::new(ModuleState::Running, enabled);
10477        seed_crash_restarts(&mut state, count);
10478        state
10479    }
10480
10481    #[test]
10482    fn daemon_owned_recovery_predicate_uses_the_pre_increment_budget() {
10483        let policy = RestartPolicy::new(3, Duration::ZERO);
10484        let now = ClockInstant::now();
10485        assert!(daemon_will_restart(
10486            &mut snapshot_with_restarts(true, 2),
10487            &policy,
10488            now
10489        ));
10490        assert!(!daemon_will_restart(
10491            &mut snapshot_with_restarts(true, 3),
10492            &policy,
10493            now
10494        ));
10495        assert!(!daemon_will_restart(
10496            &mut snapshot_with_restarts(false, 0),
10497            &policy,
10498            now
10499        ));
10500    }
10501
10502    #[test]
10503    fn crash_restart_backoff_escalates_with_in_window_count() {
10504        let policy = RestartPolicy::new(4, Duration::from_millis(100))
10505            .with_max_backoff(Duration::from_secs(30));
10506        let now = ClockInstant::now();
10507        let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
10508        let schedules = (0..4)
10509            .map(|_| {
10510                state
10511                    .next_crash_restart(&policy, now)
10512                    .expect("the test policy allows four crash restarts")
10513            })
10514            .collect::<Vec<_>>();
10515
10516        assert_eq!(
10517            schedules
10518                .iter()
10519                .map(|schedule| schedule.restart_in_window)
10520                .collect::<Vec<_>>(),
10521            vec![0, 1, 2, 3]
10522        );
10523        assert_eq!(
10524            schedules
10525                .iter()
10526                .map(|schedule| schedule.delay)
10527                .collect::<Vec<_>>(),
10528            vec![
10529                Duration::from_millis(100),
10530                Duration::from_secs(1),
10531                Duration::from_secs(10),
10532                Duration::from_secs(30),
10533            ]
10534        );
10535    }
10536
10537    #[test]
10538    fn crash_restart_backoff_resets_after_ring_clear() {
10539        let policy = RestartPolicy::new(3, Duration::from_millis(100));
10540        let now = ClockInstant::now();
10541        let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
10542        assert_eq!(
10543            state.next_crash_restart(&policy, now).unwrap().delay,
10544            Duration::from_millis(100)
10545        );
10546        assert_eq!(
10547            state.next_crash_restart(&policy, now).unwrap().delay,
10548            Duration::from_secs(1)
10549        );
10550
10551        state.clear_crash_restarts();
10552        let schedule = state
10553            .next_crash_restart(&policy, now)
10554            .expect("a cleared ring must allow another restart");
10555        assert_eq!(schedule.restart_in_window, 0);
10556        assert_eq!(schedule.delay, Duration::from_millis(100));
10557    }
10558
10559    #[test]
10560    fn crash_restart_backoff_ignores_aged_restarts() {
10561        let policy = RestartPolicy::new(3, Duration::from_millis(100));
10562        let now = ClockInstant::now();
10563        let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
10564        state
10565            .next_crash_restart(&policy, now)
10566            .expect("the first restart is allowed");
10567        state
10568            .next_crash_restart(&policy, now)
10569            .expect("the second restart is allowed");
10570        state.crash_restarts[0] = now
10571            .checked_sub(policy.window + Duration::from_secs(1))
10572            .expect("the fake clock can age a restart past the window");
10573
10574        let schedule = state
10575            .next_crash_restart(&policy, now)
10576            .expect("an aged restart must release its slot");
10577        assert_eq!(schedule.restart_in_window, 1);
10578        assert_eq!(schedule.delay, Duration::from_secs(1));
10579        assert_eq!(state.crash_restarts.len(), 2);
10580    }
10581
10582    /// The budget is a rate: the same three spent restarts refuse a respawn
10583    /// while they are recent and allow one once they have aged past the window.
10584    /// Nothing about the module changed in between, which is the whole point.
10585    #[test]
10586    fn a_budget_spent_before_the_window_no_longer_refuses() {
10587        let policy = RestartPolicy::new(3, Duration::ZERO);
10588        let mut state = snapshot_with_restarts(true, 3);
10589        let now = ClockInstant::now();
10590        assert!(!daemon_will_restart(&mut state, &policy, now));
10591
10592        assert!(daemon_will_restart(
10593            &mut state,
10594            &policy,
10595            now + policy.window + Duration::from_secs(1)
10596        ));
10597        assert!(
10598            state.crash_restarts.is_empty(),
10599            "reading the budget must drop the instants that left the window"
10600        );
10601    }
10602
10603    fn module_with_recovery_snapshot(
10604        state: ModuleState,
10605        enabled: bool,
10606        restart_count: u32,
10607    ) -> SupervisedModule {
10608        let registry = Arc::new(Registry::default());
10609        let supervisor =
10610            Supervisor::new_for_test(Arc::clone(&registry), RestartPolicy::new(3, Duration::ZERO));
10611        let runtime = supervisor.runtime_config();
10612        let spec = ModuleSpec {
10613            module_id: "recovery-snapshot".to_string(),
10614            program: fake_aft_stub_path(),
10615            args: Vec::new(),
10616            env: Vec::new(),
10617            reserved: false,
10618            reserved_prefixes: Vec::new(),
10619            protocol: ModuleProtocol::Subc,
10620            overlap: Default::default(),
10621        };
10622        let mut snapshot = SupervisorSnapshot::new(state, enabled);
10623        seed_crash_restarts(&mut snapshot, restart_count);
10624        // These tests read synthetic snapshots. A real child and monitor would
10625        // race those reads by replacing the requested state during startup.
10626        let (commands, _rx) = mpsc::channel(4);
10627        SupervisedModule {
10628            inner: Arc::new(SupervisedModuleInner {
10629                module_id: spec.module_id.clone(),
10630                registry,
10631                snapshot: Arc::new(Mutex::new(snapshot)),
10632                configuration: Arc::new(Mutex::new(SupervisedConfiguration {
10633                    spec,
10634                    health: runtime.health,
10635                })),
10636                stderr_ring: runtime.stderr_ring,
10637                terminal_ring: runtime.terminal_ring,
10638                commands,
10639                monitor: Mutex::new(None),
10640                restart_policy: runtime.restart_policy,
10641                effective_drain_timeout: runtime.effective_drain_timeout,
10642                provenance_probe: supervisor.provenance_probe.clone(),
10643            }),
10644        }
10645    }
10646
10647    #[cfg(target_os = "linux")]
10648    #[tokio::test]
10649    async fn no_cgroup_placement_does_not_block_fake_aft_stub_spawn() {
10650        let supervisor =
10651            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
10652                .with_cgroup_placement(None);
10653        let result = supervisor.spawn(ModuleSpec {
10654            module_id: "no-cgroup-placement".to_string(),
10655            program: fake_aft_stub_path(),
10656            args: Vec::new(),
10657            env: Vec::new(),
10658            reserved: false,
10659            reserved_prefixes: Vec::new(),
10660            protocol: ModuleProtocol::Subc,
10661            overlap: Default::default(),
10662        });
10663
10664        assert!(
10665            result.is_ok(),
10666            "no delegation must not turn an otherwise valid spawn into a failure: {result:?}"
10667        );
10668    }
10669
10670    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10671    async fn undecided_snapshot_uses_shared_restart_predicate() {
10672        assert!(module_with_recovery_snapshot(ModuleState::Running, true, 2)
10673            .will_recover_after_connection_loss()
10674            .unwrap());
10675        assert!(
10676            !module_with_recovery_snapshot(ModuleState::Running, true, 3)
10677                .will_recover_after_connection_loss()
10678                .unwrap()
10679        );
10680    }
10681
10682    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10683    async fn restarting_snapshot_at_exhausted_budget_is_non_terminal() {
10684        assert!(
10685            module_with_recovery_snapshot(ModuleState::Restarting, true, 3)
10686                .will_recover_after_connection_loss()
10687                .unwrap()
10688        );
10689    }
10690
10691    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10692    async fn terminal_phase_snapshots_are_terminal_before_budget_exhaustion() {
10693        assert!(!module_with_recovery_snapshot(ModuleState::Failed, true, 0)
10694            .will_recover_after_connection_loss()
10695            .unwrap());
10696        assert!(
10697            !module_with_recovery_snapshot(ModuleState::Disabled, true, 0)
10698                .will_recover_after_connection_loss()
10699                .unwrap()
10700        );
10701    }
10702
10703    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10704    async fn warming_snapshot_is_limited_to_startup_phases() {
10705        for state in [
10706            ModuleState::Starting,
10707            ModuleState::Running,
10708            ModuleState::Restarting,
10709        ] {
10710            assert!(
10711                module_with_recovery_snapshot(state, true, 0)
10712                    .is_warming()
10713                    .unwrap(),
10714                "{state:?} should be warming"
10715            );
10716        }
10717        for state in [
10718            ModuleState::Unresponsive,
10719            ModuleState::Draining,
10720            ModuleState::Stopped,
10721            ModuleState::Failed,
10722            ModuleState::Disabled,
10723        ] {
10724            assert!(
10725                !module_with_recovery_snapshot(state, true, 0)
10726                    .is_warming()
10727                    .unwrap(),
10728                "{state:?} should not be warming"
10729            );
10730        }
10731    }
10732
10733    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10734    async fn terminal_history_survives_respawn_and_keeps_both_crashes_in_order() {
10735        let registry = Arc::new(Registry::default());
10736        let supervisor =
10737            Supervisor::new_for_test(Arc::clone(&registry), RestartPolicy::new(1, Duration::ZERO));
10738        let module = supervisor
10739            .spawn(ModuleSpec {
10740                module_id: "terminal-history".to_string(),
10741                program: fake_aft_stub_path(),
10742                args: Vec::new(),
10743                env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
10744                reserved: false,
10745                reserved_prefixes: Vec::new(),
10746                protocol: ModuleProtocol::Subc,
10747                overlap: Default::default(),
10748            })
10749            .unwrap();
10750
10751        let deadline = Instant::now() + Duration::from_secs(5);
10752        loop {
10753            let history = module.terminal_history();
10754            if history.entries.len() == 2 {
10755                assert_eq!(module.status().unwrap().state, ModuleState::Failed);
10756                assert_eq!(history.dropped, 0);
10757                assert_eq!(
10758                    history
10759                        .entries
10760                        .iter()
10761                        .map(|entry| entry.exit_code)
10762                        .collect::<Vec<_>>(),
10763                    vec![Some(23), Some(23)]
10764                );
10765                assert!(history.entries[0].at_ms <= history.entries[1].at_ms);
10766                return;
10767            }
10768            assert!(
10769                Instant::now() < deadline,
10770                "module did not retain two terminal exits: {history:?}"
10771            );
10772            sleep(Duration::from_millis(10)).await;
10773        }
10774    }
10775
10776    /// A disable issued while a crash respawn is still backing off must preempt
10777    /// that respawn: the operator's stop wins, the disable must not queue behind
10778    /// the backoff, and the module must never come back up afterwards.
10779    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10780    async fn disable_during_crash_backoff_cancels_pending_respawn() {
10781        let backoff = Duration::from_secs(2);
10782        let supervisor = Supervisor::new_for_test(
10783            Arc::new(Registry::default()),
10784            RestartPolicy::new(10, backoff),
10785        );
10786        let module = supervisor
10787            .spawn(ModuleSpec {
10788                module_id: "disable-during-backoff".to_string(),
10789                program: fake_aft_stub_path(),
10790                args: Vec::new(),
10791                env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
10792                reserved: false,
10793                reserved_prefixes: Vec::new(),
10794                protocol: ModuleProtocol::Subc,
10795                overlap: Default::default(),
10796            })
10797            .unwrap();
10798
10799        // Wait for the first crash to put the module into its backoff window.
10800        let deadline = Instant::now() + Duration::from_secs(5);
10801        loop {
10802            if module.status().unwrap().state == ModuleState::Restarting {
10803                break;
10804            }
10805            assert!(
10806                Instant::now() < deadline,
10807                "module never entered the crash backoff"
10808            );
10809            sleep(Duration::from_millis(10)).await;
10810        }
10811
10812        let started = Instant::now();
10813        module.set_enabled(false).await.unwrap();
10814        let waited = started.elapsed();
10815
10816        assert!(
10817            waited < backoff / 2,
10818            "disable waited {waited:?} behind the {backoff:?} crash backoff; the operator command must preempt the pending respawn"
10819        );
10820        assert_eq!(module.status().unwrap().state, ModuleState::Disabled);
10821
10822        // Outlast the backoff: the respawn it was counting down to must never run.
10823        sleep(backoff + Duration::from_millis(500)).await;
10824        let status = module.status().unwrap();
10825        assert_eq!(status.state, ModuleState::Disabled);
10826        assert_eq!(
10827            status.spawn_generation, 1,
10828            "module respawned after the operator disabled it"
10829        );
10830    }
10831
10832    /// A `protocol: "none"` child that stays parked and exits 0 on SIGTERM:
10833    /// the shape of nats-server, the program this rule exists for.
10834    #[cfg(unix)]
10835    fn protocol_none_sigterm_exits_clean_spec(
10836        module_id: &str,
10837        dir: &std::path::Path,
10838    ) -> (ModuleSpec, PathBuf, PathBuf) {
10839        let ready = dir.join("ready");
10840        let marker = dir.join("sigterm");
10841        let spec = ModuleSpec {
10842            module_id: module_id.to_string(),
10843            program: fake_aft_stub_path(),
10844            args: Vec::new(),
10845            env: vec![
10846                ("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string()),
10847                (
10848                    "FAKE_AFT_SIGTERM_MARKER_PATH".to_string(),
10849                    marker.display().to_string(),
10850                ),
10851                (
10852                    "FAKE_AFT_NEVER_CONNECT_READY_PATH".to_string(),
10853                    ready.display().to_string(),
10854                ),
10855            ],
10856            reserved: false,
10857            reserved_prefixes: Vec::new(),
10858            protocol: ModuleProtocol::None,
10859            overlap: Default::default(),
10860        };
10861        (spec, ready, marker)
10862    }
10863
10864    /// Wait for a file the child writes, so a signal is never sent before the
10865    /// child's SIGTERM handler is installed (the default disposition would
10866    /// kill it by signal and the exit would not be clean).
10867    #[cfg(unix)]
10868    async fn wait_for_file(path: &std::path::Path) {
10869        let deadline = Instant::now() + Duration::from_secs(10);
10870        while !path.exists() {
10871            assert!(
10872                Instant::now() < deadline,
10873                "{} never appeared",
10874                path.display()
10875            );
10876            sleep(Duration::from_millis(10)).await;
10877        }
10878    }
10879
10880    /// A protocol-none module that exits 0 because something OUTSIDE the
10881    /// supervisor sent it SIGTERM is respawned, and the terminal record carries
10882    /// the crash-path disposition rather than `stopped`.
10883    #[cfg(unix)]
10884    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10885    async fn protocol_none_unrequested_clean_exit_restarts_as_a_crash() {
10886        let dir = cortexkit_test_support::ScratchDir::new("none-unrequested-clean-exit");
10887        let (spec, ready, marker) =
10888            protocol_none_sigterm_exits_clean_spec("none-unrequested-clean-exit", dir.path());
10889        let supervisor = Supervisor::new_for_test(
10890            Arc::new(Registry::default()),
10891            RestartPolicy::new(3, Duration::ZERO),
10892        );
10893        let module = supervisor.spawn(spec).unwrap();
10894        wait_for_file(&ready).await;
10895        // The ready file proves the child installed its SIGTERM handler, not
10896        // that the supervisor has processed the privacy trampoline's exec
10897        // acknowledgement. On macOS status withholds the pid until then.
10898        let deadline = Instant::now() + Duration::from_secs(10);
10899        let first_pid = loop {
10900            let status = module.status().unwrap();
10901            if status.state == ModuleState::Running {
10902                if let Some(pid) = status.pid {
10903                    break pid;
10904                }
10905            }
10906            assert!(
10907                Instant::now() < deadline,
10908                "a running module must report its pid after exec confirmation: {status:?}"
10909            );
10910            sleep(Duration::from_millis(10)).await;
10911        };
10912
10913        rustix::process::kill_process(
10914            rustix::process::Pid::from_raw(i32::try_from(first_pid).unwrap()).unwrap(),
10915            rustix::process::Signal::TERM,
10916        )
10917        .unwrap();
10918
10919        let deadline = Instant::now() + Duration::from_secs(10);
10920        let respawned = loop {
10921            let status = module.status().unwrap();
10922            if status.state == ModuleState::Running
10923                && status.pid.is_some_and(|pid| pid != first_pid)
10924            {
10925                break status;
10926            }
10927            assert!(
10928                Instant::now() < deadline,
10929                "protocol-none module was not respawned after an unrequested clean exit: {status:?}"
10930            );
10931            sleep(Duration::from_millis(10)).await;
10932        };
10933        assert_eq!(respawned.spawn_generation, 2);
10934        assert!(
10935            marker.exists(),
10936            "the child must have exited through its SIGTERM handler (exit 0), or this proves nothing about clean exits"
10937        );
10938
10939        let history = module.terminal_history();
10940        assert_eq!(history.entries.len(), 1, "{history:?}");
10941        let entry = &history.entries[0];
10942        assert_eq!(entry.exit_code, Some(0));
10943        assert_eq!(entry.exit_kind, subc_control::TerminalExitKind::Clean);
10944        assert_eq!(entry.disposition, TerminalDisposition::Restarting);
10945
10946        module.stop().await.unwrap();
10947    }
10948
10949    /// Repeated unrequested clean exits of a protocol-none module spend the
10950    /// restart budget exactly as crashes do, and the module ends `failed` with
10951    /// the budget named.
10952    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10953    async fn protocol_none_repeated_clean_exits_exhaust_the_restart_budget() {
10954        let supervisor = Supervisor::new_for_test(
10955            Arc::new(Registry::default()),
10956            RestartPolicy::new(1, Duration::ZERO),
10957        );
10958        let module = supervisor
10959            .spawn(ModuleSpec {
10960                module_id: "none-clean-exit-budget".to_string(),
10961                program: fake_aft_stub_path(),
10962                args: Vec::new(),
10963                env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "0".to_string())],
10964                reserved: false,
10965                reserved_prefixes: Vec::new(),
10966                protocol: ModuleProtocol::None,
10967                overlap: Default::default(),
10968            })
10969            .unwrap();
10970
10971        // Failed follows the terminal write; this deadline only bounds a hang,
10972        // not an assumed duration for the two launches or their exit recording.
10973        let deadline = Instant::now() + Duration::from_secs(10);
10974        loop {
10975            let status = module.status().unwrap();
10976            if status.state == ModuleState::Failed {
10977                break;
10978            }
10979            assert!(
10980                Instant::now() < deadline,
10981                "module never exhausted its budget: {status:?} {:?}",
10982                module.terminal_history()
10983            );
10984            sleep(Duration::from_millis(10)).await;
10985        }
10986        let history = module.terminal_history();
10987        assert_eq!(
10988            history
10989                .entries
10990                .iter()
10991                .map(|entry| (entry.exit_code, entry.disposition.clone()))
10992                .collect::<Vec<_>>(),
10993            vec![
10994                (Some(0), TerminalDisposition::Restarting),
10995                (Some(0), TerminalDisposition::Failed),
10996            ]
10997        );
10998        let detail = history.entries[1]
10999            .disposition_detail
11000            .as_deref()
11001            .expect("a budget failure names the budget");
11002        assert!(detail.contains("max_restarts=1"), "{detail}");
11003        assert_eq!(module.status().unwrap().spawn_generation, 2);
11004    }
11005
11006    #[test]
11007    fn restart_budget_failure_is_published_after_its_terminal_record() {
11008        use std::io::{Read, Write};
11009        let supervisor = Supervisor::new_for_test(
11010            Arc::new(Registry::default()),
11011            RestartPolicy::new(0, Duration::ZERO),
11012        );
11013        let runtime = supervisor.runtime_config();
11014        let spec = ModuleSpec {
11015            protocol: ModuleProtocol::None,
11016            ..windowed_crash_spec("budget-publication-order")
11017        };
11018        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(
11019            ModuleState::Running,
11020            true,
11021        )));
11022        lock_snapshot(&snapshot).unwrap().process_alive = true;
11023        let reaped_snapshot = snapshot.clone();
11024        let mut barrier = terminal_record_barrier(&runtime.spawn_events);
11025        let history = runtime.terminal_ring.clone();
11026        let reap = std::thread::spawn(move || {
11027            tokio::runtime::Builder::new_current_thread()
11028                .enable_all()
11029                .build()
11030                .unwrap()
11031                .block_on(on_child_exit(
11032                    &spec,
11033                    runtime.restart_policy,
11034                    &supervisor.registry,
11035                    &reaped_snapshot,
11036                    &runtime.terminal_ring,
11037                    &runtime.spawn_events,
11038                    &runtime.child_roster,
11039                    ExitReport {
11040                        kind: ExitKind::Clean,
11041                        code: Some(0),
11042                        signal: None,
11043                        at_ms: 1,
11044                    },
11045                ))
11046        });
11047        let mut reached = [0];
11048        barrier.read_exact(&mut reached).unwrap();
11049        assert_eq!(&reached, b"R");
11050        let (before_state, before_alive) = {
11051            let state = lock_snapshot(&snapshot).unwrap();
11052            (state.state, state.process_alive)
11053        };
11054        let before_history = history.lock().unwrap().snapshot();
11055        barrier.write_all(b"X").unwrap();
11056        assert!(matches!(reap.join().unwrap(), NextAction::Stop { .. }));
11057        assert!(before_history.entries.is_empty());
11058        assert_ne!(
11059            before_state,
11060            ModuleState::Failed,
11061            "Failed was visible before its terminal record could be written"
11062        );
11063        assert!(before_alive);
11064        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
11065        let history = history.lock().unwrap().snapshot();
11066        assert_eq!(history.entries.len(), 1);
11067        assert_eq!(history.entries[0].exit_code, Some(0));
11068        assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
11069    }
11070
11071    fn terminal_record_barrier(events: &super::SpawnEventFeed) -> std::net::TcpStream {
11072        use std::net::{TcpListener, TcpStream};
11073        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
11074        let writer = TcpStream::connect(listener.local_addr().unwrap()).unwrap();
11075        let (barrier, _) = listener.accept().unwrap();
11076        for stream in [&writer, &barrier] {
11077            stream
11078                .set_read_timeout(Some(Duration::from_secs(10)))
11079                .unwrap();
11080            stream
11081                .set_write_timeout(Some(Duration::from_secs(10)))
11082                .unwrap();
11083        }
11084        *events.1.lock().unwrap() = Some(writer);
11085        barrier
11086    }
11087
11088    pub(super) fn before_terminal_record(events: &super::SpawnEventFeed) {
11089        use std::io::{Read, Write};
11090        let barrier = events.1.lock().unwrap().take();
11091        if let Some(mut barrier) = barrier {
11092            barrier.write_all(b"R").unwrap();
11093            let mut release = [0];
11094            barrier.read_exact(&mut release).unwrap();
11095            assert_eq!(&release, b"X");
11096        }
11097    }
11098
11099    #[test]
11100    fn health_restart_budget_failure_is_published_after_its_terminal_record() {
11101        use std::io::{Read, Write};
11102
11103        let supervisor = Supervisor::new_for_test(
11104            Arc::new(Registry::default()),
11105            RestartPolicy::new(0, Duration::ZERO),
11106        );
11107        let mut runtime = supervisor.runtime_config();
11108        let module = module_with_recovery_snapshot(ModuleState::Running, true, 0);
11109        let snapshot = module.inner.snapshot.clone();
11110        runtime.terminal_ring = module.inner.terminal_ring.clone();
11111        let spec = ModuleSpec {
11112            module_id: module.module_id().into(),
11113            program: fake_aft_stub_path(),
11114            protocol: ModuleProtocol::None,
11115            env: vec![("FAKE_AFT_EXIT_CODE".into(), "17".into())],
11116            ..windowed_crash_spec(module.module_id())
11117        };
11118        let mut barrier = terminal_record_barrier(&runtime.spawn_events);
11119        let reap = std::thread::spawn(move || {
11120            tokio::runtime::Builder::new_current_thread()
11121                .enable_all()
11122                .build()
11123                .unwrap()
11124                .block_on(async {
11125                    let mut child =
11126                        Some(spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap());
11127                    health_restart_child(
11128                        &spec,
11129                        &runtime,
11130                        &supervisor.registry,
11131                        &supervisor.process_liveness,
11132                        &snapshot,
11133                        &mut child,
11134                        SupervisorHealthStatus::Failing,
11135                        Some("health probe did not answer"),
11136                        1,
11137                    )
11138                    .await
11139                    .unwrap();
11140                    assert!(child.is_none());
11141                });
11142        });
11143        // The loopback barrier acknowledges entry into terminal recording by
11144        // health_restart_child's drain, then waits for the test's release byte.
11145        // Status and history are observed while that writer is held, not after
11146        // an elapsed-time guess about when the health action has completed.
11147        let mut reached = [0];
11148        barrier.read_exact(&mut reached).unwrap();
11149        assert_eq!(&reached, b"R");
11150        let before_status = module.status().unwrap();
11151        let before_history = module.terminal_history();
11152        barrier.write_all(b"X").unwrap();
11153        reap.join().unwrap();
11154        assert!(before_history.entries.is_empty());
11155        assert_ne!(
11156            before_status.state,
11157            ModuleState::Failed,
11158            "Failed was visible before its terminal record could be written"
11159        );
11160        assert!(
11161            before_status.process_alive,
11162            "process death was visible before its terminal record could be written"
11163        );
11164        let failed = module.status().unwrap();
11165        assert_eq!(failed.state, ModuleState::Failed);
11166        assert!(!failed.process_alive);
11167        let history = module.terminal_history();
11168        assert_eq!(history.entries.len(), 1);
11169        assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
11170        assert_eq!(
11171            history.entries[0].disposition_detail.as_deref(),
11172            Some("crash budget exhausted: max_restarts=0 within window_secs=600")
11173        );
11174    }
11175
11176    #[test]
11177    fn observed_exits_publish_process_death_after_terminal_history() {
11178        use std::io::{Read, Write};
11179        for (kind, enabled, shutdown, wait_error, expected_state) in [
11180            (ExitKind::Clean, true, false, false, ModuleState::Stopped),
11181            (ExitKind::Crash, true, false, false, ModuleState::Restarting),
11182            (ExitKind::Crash, false, false, false, ModuleState::Disabled),
11183            (
11184                ExitKind::DeliberateSeverance,
11185                true,
11186                false,
11187                false,
11188                ModuleState::Restarting,
11189            ),
11190            #[cfg(unix)]
11191            (ExitKind::Crash, true, true, false, ModuleState::Stopped),
11192            (ExitKind::Crash, true, false, true, ModuleState::Failed),
11193        ] {
11194            let supervisor = Supervisor::new_for_test(
11195                Arc::new(Registry::default()),
11196                RestartPolicy::new(1, Duration::ZERO),
11197            );
11198            let runtime = supervisor.runtime_config();
11199            #[cfg(unix)]
11200            if shutdown {
11201                runtime.child_roster.close();
11202            }
11203            #[cfg(not(unix))]
11204            let _ = shutdown;
11205            let spec = windowed_crash_spec("exit-publication-order");
11206            let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(
11207                ModuleState::Running,
11208                enabled,
11209            )));
11210            lock_snapshot(&snapshot).unwrap().process_alive = true;
11211            let reaped_snapshot = snapshot.clone();
11212            let history = runtime.terminal_ring.clone();
11213            let mut barrier = terminal_record_barrier(&runtime.spawn_events);
11214            let reap = std::thread::spawn(move || {
11215                if wait_error {
11216                    super::fail_after_wait_error(
11217                        &spec.module_id,
11218                        &reaped_snapshot,
11219                        &runtime.terminal_ring,
11220                        &runtime.spawn_events,
11221                        "wait failed".into(),
11222                    );
11223                } else {
11224                    tokio::runtime::Builder::new_current_thread()
11225                        .enable_all()
11226                        .build()
11227                        .unwrap()
11228                        .block_on(on_child_exit(
11229                            &spec,
11230                            runtime.restart_policy,
11231                            &supervisor.registry,
11232                            &reaped_snapshot,
11233                            &runtime.terminal_ring,
11234                            &runtime.spawn_events,
11235                            &runtime.child_roster,
11236                            ExitReport {
11237                                kind,
11238                                code: Some(0),
11239                                signal: None,
11240                                at_ms: 1,
11241                            },
11242                        ));
11243                }
11244            });
11245            let mut reached = [0];
11246            barrier.read_exact(&mut reached).unwrap();
11247            assert_eq!(&reached, b"R");
11248            let before_alive = lock_snapshot(&snapshot).unwrap().process_alive;
11249            let before_history = history.lock().unwrap().snapshot();
11250            barrier.write_all(b"X").unwrap();
11251            reap.join().unwrap();
11252            assert!(
11253                before_alive,
11254                "{kind:?}: death was published before recording"
11255            );
11256            assert!(before_history.entries.is_empty());
11257            assert_eq!(lock_snapshot(&snapshot).unwrap().state, expected_state);
11258            assert!(!lock_snapshot(&snapshot).unwrap().process_alive);
11259            assert_eq!(history.lock().unwrap().snapshot().entries.len(), 1);
11260        }
11261    }
11262
11263    #[tokio::test]
11264    async fn health_restart_budget_failure_remains_failed_when_journal_append_fails() {
11265        let dir = cortexkit_test_support::ScratchDir::new("health-budget-journal-failure");
11266        let path = dir.join("terminals.jsonl");
11267        std::fs::create_dir(&path).unwrap();
11268        let supervisor = Supervisor::new_for_test(
11269            Arc::new(Registry::default()),
11270            RestartPolicy::new(0, Duration::ZERO),
11271        )
11272        .with_terminal_journal(path, "health-budget-journal-failure".into());
11273        let runtime = supervisor.runtime_config();
11274        let spec = ModuleSpec {
11275            program: fake_aft_stub_path(),
11276            protocol: ModuleProtocol::None,
11277            env: vec![("FAKE_AFT_EXIT_CODE".into(), "17".into())],
11278            ..windowed_crash_spec("health-budget-journal-failure")
11279        };
11280        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11281        let mut child = Some(spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap());
11282        health_restart_child(
11283            &spec,
11284            &runtime,
11285            &supervisor.registry,
11286            &supervisor.process_liveness,
11287            &snapshot,
11288            &mut child,
11289            SupervisorHealthStatus::Failing,
11290            None,
11291            1,
11292        )
11293        .await
11294        .unwrap();
11295        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
11296        assert!(!lock_snapshot(&snapshot).unwrap().process_alive);
11297        let history = runtime
11298            .terminal_ring
11299            .lock()
11300            .unwrap()
11301            .durable_history(&spec.module_id);
11302        assert!(history.journal_write_failures > 0);
11303        assert_eq!(history.entries.len(), 1);
11304        assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
11305    }
11306
11307    #[test]
11308    fn health_restart_budget_failure_remains_failed_when_exit_recording_panics() {
11309        let supervisor = Supervisor::new_for_test(
11310            Arc::new(Registry::default()),
11311            RestartPolicy::new(0, Duration::ZERO),
11312        );
11313        let runtime = supervisor.runtime_config();
11314        let spec = ModuleSpec {
11315            program: fake_aft_stub_path(),
11316            protocol: ModuleProtocol::None,
11317            env: vec![("FAKE_AFT_EXIT_CODE".into(), "17".into())],
11318            ..windowed_crash_spec("health-budget-recording-panic")
11319        };
11320        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11321        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
11322            tokio::runtime::Builder::new_current_thread()
11323                .enable_all()
11324                .build()
11325                .unwrap()
11326                .block_on(async {
11327                    let mut child =
11328                        Some(spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap());
11329                    runtime.spawn_events.0.lock().unwrap().seq = u64::MAX;
11330                    health_restart_child(
11331                        &spec,
11332                        &runtime,
11333                        &supervisor.registry,
11334                        &supervisor.process_liveness,
11335                        &snapshot,
11336                        &mut child,
11337                        SupervisorHealthStatus::Failing,
11338                        None,
11339                        1,
11340                    )
11341                    .await
11342                    .unwrap();
11343                });
11344        }));
11345        let panic = result.expect_err("recording must still unwind");
11346        assert_eq!(
11347            panic.downcast_ref::<String>().map(String::as_str),
11348            Some("spawn event sequence exhausted")
11349        );
11350        let state = lock_snapshot(&snapshot).unwrap();
11351        assert_eq!(state.state, ModuleState::Failed);
11352        assert!(!state.process_alive);
11353        assert!(state.last_exit.is_some());
11354    }
11355
11356    #[tokio::test]
11357    async fn restart_budget_failure_remains_failed_when_journal_append_fails() {
11358        let dir = cortexkit_test_support::ScratchDir::new("budget-journal-failure");
11359        let path = dir.join("terminals.jsonl");
11360        std::fs::create_dir(&path).unwrap();
11361        let supervisor = Supervisor::new_for_test(
11362            Arc::new(Registry::default()),
11363            RestartPolicy::new(0, Duration::ZERO),
11364        )
11365        .with_terminal_journal(path, "budget-journal-failure".into());
11366        let runtime = supervisor.runtime_config();
11367        let spec = windowed_crash_spec("budget-journal-failure");
11368        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11369        assert!(matches!(
11370            on_child_exit(
11371                &spec,
11372                runtime.restart_policy,
11373                &supervisor.registry,
11374                &snapshot,
11375                &runtime.terminal_ring,
11376                &runtime.spawn_events,
11377                &runtime.child_roster,
11378                crash_exit_report(1),
11379            )
11380            .await,
11381            NextAction::Stop { .. }
11382        ));
11383        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
11384        let history = runtime
11385            .terminal_ring
11386            .lock()
11387            .unwrap()
11388            .durable_history(&spec.module_id);
11389        assert!(history.journal_write_failures > 0);
11390        assert_eq!(history.entries.len(), 1);
11391        assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
11392    }
11393
11394    #[test]
11395    fn restart_budget_failure_remains_failed_when_exit_recording_panics() {
11396        let supervisor = Supervisor::new_for_test(
11397            Arc::new(Registry::default()),
11398            RestartPolicy::new(0, Duration::ZERO),
11399        );
11400        let runtime = supervisor.runtime_config();
11401        let spec = windowed_crash_spec("budget-recording-panic");
11402        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11403        runtime.spawn_events.emit_spawned(&spec.module_id, 1, 1);
11404        // Exhausting the event sequence makes emit_exited panic before the
11405        // terminal write, exercising publication on the recording unwind.
11406        runtime.spawn_events.0.lock().unwrap().seq = u64::MAX;
11407        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
11408            tokio::runtime::Builder::new_current_thread()
11409                .enable_all()
11410                .build()
11411                .unwrap()
11412                .block_on(on_child_exit(
11413                    &spec,
11414                    runtime.restart_policy,
11415                    &supervisor.registry,
11416                    &snapshot,
11417                    &runtime.terminal_ring,
11418                    &runtime.spawn_events,
11419                    &runtime.child_roster,
11420                    crash_exit_report(1),
11421                ))
11422        }));
11423        let panic = result.err().expect("recording must still unwind");
11424        assert_eq!(
11425            panic.downcast_ref::<String>().map(String::as_str),
11426            Some("spawn event sequence exhausted")
11427        );
11428        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
11429    }
11430
11431    /// A stop the supervisor itself requests still stops a protocol-none
11432    /// module, even though the child answers the SIGTERM with exit 0.
11433    #[cfg(unix)]
11434    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11435    async fn protocol_none_requested_stop_and_disable_do_not_respawn() {
11436        for disable in [false, true] {
11437            let label = if disable {
11438                "none-requested-disable"
11439            } else {
11440                "none-requested-stop"
11441            };
11442            let dir = cortexkit_test_support::ScratchDir::new(label);
11443            let (spec, ready, marker) = protocol_none_sigterm_exits_clean_spec(label, dir.path());
11444            let supervisor = Supervisor::new_for_test(
11445                Arc::new(Registry::default()),
11446                RestartPolicy::new(3, Duration::ZERO),
11447            );
11448            let module = supervisor.spawn(spec).unwrap();
11449            wait_for_file(&ready).await;
11450
11451            if disable {
11452                module.set_enabled(false).await.unwrap();
11453            } else {
11454                module.stop().await.unwrap();
11455            }
11456            assert!(
11457                marker.exists(),
11458                "{label}: the child must have left through its SIGTERM handler with exit 0"
11459            );
11460
11461            // Long enough for a zero-backoff respawn to have happened if the
11462            // exit had been treated as a crash.
11463            sleep(Duration::from_millis(500)).await;
11464            let status = module.status().unwrap();
11465            let expected = if disable {
11466                ModuleState::Disabled
11467            } else {
11468                ModuleState::Stopped
11469            };
11470            assert_eq!(status.state, expected, "{label}");
11471            assert_eq!(
11472                status.spawn_generation, 1,
11473                "{label}: respawned after a requested stop"
11474            );
11475            let history = module.terminal_history();
11476            assert_eq!(history.entries.len(), 1, "{label}: {history:?}");
11477            assert_eq!(history.entries[0].exit_code, Some(0), "{label}");
11478            assert_ne!(
11479                history.entries[0].disposition,
11480                TerminalDisposition::Restarting,
11481                "{label}"
11482            );
11483        }
11484    }
11485
11486    /// A subc-wire module that exits 0 on its own is still a stop: the
11487    /// protocol-none rule must not reach it.
11488    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11489    async fn subc_wire_clean_exit_is_still_a_stop() {
11490        let supervisor = Supervisor::new_for_test(
11491            Arc::new(Registry::default()),
11492            RestartPolicy::new(3, Duration::ZERO),
11493        );
11494        let module = supervisor
11495            .spawn(ModuleSpec {
11496                module_id: "wire-clean-exit".to_string(),
11497                program: fake_aft_stub_path(),
11498                args: Vec::new(),
11499                env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "0".to_string())],
11500                reserved: false,
11501                reserved_prefixes: Vec::new(),
11502                protocol: ModuleProtocol::Subc,
11503                overlap: Default::default(),
11504            })
11505            .unwrap();
11506
11507        let deadline = Instant::now() + Duration::from_secs(10);
11508        while module.terminal_history().entries.is_empty() {
11509            assert!(Instant::now() < deadline, "module never exited");
11510            sleep(Duration::from_millis(10)).await;
11511        }
11512        // Long enough for a zero-backoff respawn to have happened.
11513        sleep(Duration::from_millis(500)).await;
11514        let status = module.status().unwrap();
11515        assert_eq!(status.state, ModuleState::Stopped);
11516        assert_eq!(status.spawn_generation, 1);
11517        let history = module.terminal_history();
11518        assert_eq!(history.entries.len(), 1, "{history:?}");
11519        assert_eq!(history.entries[0].exit_code, Some(0));
11520        assert_eq!(history.entries[0].disposition, TerminalDisposition::Stopped);
11521    }
11522
11523    #[cfg(unix)]
11524    #[tokio::test]
11525    async fn reload_exit_keeps_roster_until_terminal_is_recorded_during_shutdown() {
11526        let dir = cortexkit_test_support::ScratchDir::new("reload-roster-terminal-order");
11527        let record = dir.join("live-children.json");
11528        let supervisor = Supervisor::new_for_test(
11529            Arc::new(Registry::default()),
11530            RestartPolicy::new(0, Duration::ZERO),
11531        );
11532        let mut runtime = supervisor.runtime_config();
11533        runtime.child_roster.record_to(record.clone());
11534        let gate = Arc::new(super::ReloadExitRecordGate::default());
11535        runtime.test_reload_exit_record_gate = Some(Arc::clone(&gate));
11536        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11537        let spec = ModuleSpec {
11538            module_id: "reload-exit-roster".into(),
11539            program: fake_aft_stub_path(),
11540            args: Vec::new(),
11541            env: vec![("FAKE_AFT_EXIT_CODE".into(), "23".into())],
11542            reserved: false,
11543            reserved_prefixes: Vec::new(),
11544            protocol: ModuleProtocol::Subc,
11545            overlap: Default::default(),
11546        };
11547        let mut child = None;
11548        let reload = super::finish_reload_child(
11549            &spec,
11550            &runtime,
11551            &supervisor.registry,
11552            &supervisor.process_liveness,
11553            &snapshot,
11554            &mut child,
11555        );
11556        tokio::pin!(reload);
11557        tokio::select! {
11558            result = &mut reload => panic!("reload missed the before-record gate: {result:?}"),
11559            _ = gate.reached.notified() => {}
11560        }
11561        assert!(runtime
11562            .terminal_ring
11563            .lock()
11564            .unwrap()
11565            .snapshot()
11566            .entries
11567            .is_empty());
11568        assert_eq!(
11569            crate::live_children::read_record(&record).unwrap().len(),
11570            1,
11571            "shutdown must still wait for the reaped child until its terminal record exists"
11572        );
11573        runtime.child_roster.close();
11574        gate.resume.notify_one();
11575        assert!(reload.await.is_err());
11576        assert!(crate::live_children::read_record(&record)
11577            .unwrap()
11578            .is_empty());
11579        let history = runtime.terminal_ring.lock().unwrap().snapshot();
11580        assert_eq!(history.entries.len(), 1);
11581        assert_eq!(
11582            history.entries[0].disposition,
11583            TerminalDisposition::DaemonShutdown
11584        );
11585    }
11586
11587    /// Each restart-producing arm has its own state transition. Keeping their
11588    /// lifetime count assertions adjacent prevents a later new arm from silently
11589    /// spending budget without recording the historical restart.
11590    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11591    async fn every_restart_increment_path_advances_lifetime_count() {
11592        let supervisor = Supervisor::new_for_test(
11593            Arc::new(Registry::default()),
11594            RestartPolicy::new(1, Duration::ZERO),
11595        );
11596        let runtime = supervisor.runtime_config();
11597        let spec = ModuleSpec {
11598            module_id: "lifetime-increment-path".to_string(),
11599            program: PathBuf::from("/unused/lifetime-increment-path"),
11600            args: Vec::new(),
11601            env: Vec::new(),
11602            reserved: false,
11603            reserved_prefixes: Vec::new(),
11604            protocol: ModuleProtocol::Subc,
11605            overlap: Default::default(),
11606        };
11607
11608        let crash_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11609        assert!(matches!(
11610            on_child_exit(
11611                &spec,
11612                runtime.restart_policy,
11613                &supervisor.registry,
11614                &crash_snapshot,
11615                &runtime.terminal_ring,
11616                &runtime.spawn_events,
11617                &runtime.child_roster,
11618                ExitReport {
11619                    kind: ExitKind::Crash,
11620                    code: Some(1),
11621                    signal: None,
11622                    at_ms: 1,
11623                },
11624            )
11625            .await,
11626            NextAction::Restart { schedule: _ }
11627        ));
11628        let (crash_restarts, crash_lifetime) = {
11629            let state = lock_snapshot(&crash_snapshot).unwrap();
11630            (state.crash_restarts.len(), state.lifetime_restarts)
11631        };
11632        assert_eq!(crash_restarts, 1);
11633        assert_eq!(crash_lifetime, 1);
11634
11635        let health_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11636        let mut health_child = None;
11637        assert!(matches!(
11638            health_restart_child(
11639                &spec,
11640                &runtime,
11641                &supervisor.registry,
11642                &supervisor.process_liveness,
11643                &health_snapshot,
11644                &mut health_child,
11645                SupervisorHealthStatus::Failing,
11646                None,
11647                2,
11648            )
11649            .await,
11650            Ok(())
11651        ));
11652        assert!(health_child.is_none());
11653        assert!(lock_snapshot(&health_snapshot).unwrap().respawn_pending);
11654        assert!(runtime.scheduled_respawn.lock().unwrap().take().is_some());
11655        let (health_restarts, health_lifetime) = {
11656            let state = lock_snapshot(&health_snapshot).unwrap();
11657            (state.crash_restarts.len(), state.lifetime_restarts)
11658        };
11659        assert_eq!(health_restarts, 1);
11660        assert_eq!(health_lifetime, 1);
11661
11662        let reload_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11663        let mut reload_child = None;
11664        assert!(matches!(
11665            handle_reload_spawn_failure(
11666                &spec,
11667                &runtime,
11668                &supervisor.process_liveness,
11669                &reload_snapshot,
11670                &mut reload_child,
11671                "forced reload spawn failure".to_string(),
11672            )
11673            .await,
11674            Err(SuperviseError::ReloadFailed { .. })
11675        ));
11676        let (reload_restarts, reload_lifetime) = {
11677            let state = lock_snapshot(&reload_snapshot).unwrap();
11678            (state.crash_restarts.len(), state.lifetime_restarts)
11679        };
11680        assert_eq!(reload_restarts, 1);
11681        assert_eq!(reload_lifetime, 1);
11682    }
11683
11684    #[tokio::test]
11685    async fn deliberately_severed_live_child_records_lifetime_without_spending_restart_budget() {
11686        let supervisor = Supervisor::new_for_test(
11687            Arc::new(Registry::default()),
11688            RestartPolicy::new(3, Duration::ZERO),
11689        );
11690        let runtime = supervisor.runtime_config();
11691        let spec = ModuleSpec {
11692            module_id: "deliberately-severed".to_string(),
11693            program: PathBuf::from("/unused/deliberately-severed"),
11694            args: Vec::new(),
11695            env: Vec::new(),
11696            reserved: false,
11697            reserved_prefixes: Vec::new(),
11698            protocol: ModuleProtocol::Subc,
11699            overlap: Default::default(),
11700        };
11701        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11702        let process = ProcessIdentity {
11703            pid: 41,
11704            start_time: 101,
11705        };
11706        record_deliberate_severance(&snapshot, process).unwrap();
11707        let exit_report = apply_deliberate_severance_marker(
11708            &snapshot,
11709            Some(process),
11710            ExitReport {
11711                kind: ExitKind::Crash,
11712                code: Some(1),
11713                signal: None,
11714                at_ms: 1,
11715            },
11716        );
11717        assert_eq!(exit_report.kind, ExitKind::DeliberateSeverance);
11718
11719        assert!(matches!(
11720            on_child_exit(
11721                &spec,
11722                runtime.restart_policy,
11723                &supervisor.registry,
11724                &snapshot,
11725                &runtime.terminal_ring,
11726                &runtime.spawn_events,
11727                &runtime.child_roster,
11728                exit_report,
11729            )
11730            .await,
11731            NextAction::Restart { schedule: _ }
11732        ));
11733        let state = lock_snapshot(&snapshot).unwrap();
11734        assert_eq!(state.lifetime_restarts, 1);
11735        assert_eq!(state.crash_restarts.len(), 0);
11736    }
11737
11738    #[tokio::test]
11739    async fn genuine_crash_spends_restart_budget_and_records_lifetime() {
11740        let supervisor = Supervisor::new_for_test(
11741            Arc::new(Registry::default()),
11742            RestartPolicy::new(3, Duration::ZERO),
11743        );
11744        let runtime = supervisor.runtime_config();
11745        let spec = ModuleSpec {
11746            module_id: "genuine-crash".to_string(),
11747            program: PathBuf::from("/unused/genuine-crash"),
11748            args: Vec::new(),
11749            env: Vec::new(),
11750            reserved: false,
11751            reserved_prefixes: Vec::new(),
11752            protocol: ModuleProtocol::Subc,
11753            overlap: Default::default(),
11754        };
11755        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11756
11757        assert!(matches!(
11758            on_child_exit(
11759                &spec,
11760                runtime.restart_policy,
11761                &supervisor.registry,
11762                &snapshot,
11763                &runtime.terminal_ring,
11764                &runtime.spawn_events,
11765                &runtime.child_roster,
11766                ExitReport {
11767                    kind: ExitKind::Crash,
11768                    code: Some(1),
11769                    signal: None,
11770                    at_ms: 1,
11771                },
11772            )
11773            .await,
11774            NextAction::Restart { schedule: _ }
11775        ));
11776        let state = lock_snapshot(&snapshot).unwrap();
11777        assert_eq!(state.lifetime_restarts, 1);
11778        assert_eq!(state.crash_restarts.len(), 1);
11779    }
11780
11781    fn crash_exit_report(at_ms: u64) -> ExitReport {
11782        ExitReport {
11783            kind: ExitKind::Crash,
11784            code: Some(1),
11785            signal: None,
11786            at_ms,
11787        }
11788    }
11789
11790    fn windowed_crash_spec(module_id: &str) -> ModuleSpec {
11791        ModuleSpec {
11792            module_id: module_id.to_string(),
11793            program: PathBuf::from("/unused").join(module_id),
11794            args: Vec::new(),
11795            env: Vec::new(),
11796            reserved: false,
11797            reserved_prefixes: Vec::new(),
11798            protocol: ModuleProtocol::Subc,
11799            overlap: Default::default(),
11800        }
11801    }
11802
11803    /// A real crash loop still stops. Three crashes with nothing aging out spend
11804    /// a budget of two and the third respawn is refused, and both surfaces an
11805    /// operator has -- the log line and the retained terminal record -- name the
11806    /// window rather than only the cap, because `max_restarts=2` alone is what
11807    /// this budget used to mean.
11808    #[tokio::test]
11809    async fn three_crashes_inside_the_window_stop_the_module_and_name_the_window() {
11810        let (logs, _guard) = crate::router::test_log::log_capture(tracing::Level::ERROR);
11811        let supervisor = Supervisor::new_for_test(
11812            Arc::new(Registry::default()),
11813            RestartPolicy::new(2, Duration::ZERO),
11814        );
11815        let runtime = supervisor.runtime_config();
11816        let spec = windowed_crash_spec("crash-loop-in-window");
11817        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11818
11819        for attempt in 1..=2 {
11820            assert!(
11821                matches!(
11822                    on_child_exit(
11823                        &spec,
11824                        runtime.restart_policy,
11825                        &supervisor.registry,
11826                        &snapshot,
11827                        &runtime.terminal_ring,
11828                        &runtime.spawn_events,
11829                        &runtime.child_roster,
11830                        crash_exit_report(attempt),
11831                    )
11832                    .await,
11833                    NextAction::Restart { schedule: _ }
11834                ),
11835                "crash {attempt} is inside the budget and must respawn"
11836            );
11837        }
11838
11839        assert!(matches!(
11840            on_child_exit(
11841                &spec,
11842                runtime.restart_policy,
11843                &supervisor.registry,
11844                &snapshot,
11845                &runtime.terminal_ring,
11846                &runtime.spawn_events,
11847                &runtime.child_roster,
11848                crash_exit_report(3),
11849            )
11850            .await,
11851            NextAction::Stop { .. }
11852        ));
11853
11854        {
11855            let state = lock_snapshot(&snapshot).unwrap();
11856            assert_eq!(state.state, ModuleState::Failed);
11857            assert_eq!(state.crash_restarts.len(), 2);
11858            assert_eq!(state.lifetime_restarts, 2);
11859        }
11860
11861        let history = runtime
11862            .terminal_ring
11863            .lock()
11864            .expect("terminal ring is not poisoned")
11865            .snapshot();
11866        let last = history
11867            .entries
11868            .last()
11869            .expect("the refused crash is retained");
11870        assert_eq!(last.disposition, TerminalDisposition::Failed);
11871        assert_eq!(
11872            last.disposition_detail.as_deref(),
11873            Some("crash budget exhausted: max_restarts=2 within window_secs=600")
11874        );
11875
11876        let captured = crate::router::test_log::captured_logs(&logs);
11877        assert!(
11878            captured.contains("crash budget exhausted: max_restarts=2 within window_secs=600"),
11879            "the stop must be logged with its window: {captured}"
11880        );
11881    }
11882
11883    /// The rate, stated as a test: three crashes where the first has aged past
11884    /// the window are two crashes as far as the budget is concerned, so the
11885    /// third respawn is allowed and the ring holds only the two recent ones.
11886    ///
11887    /// This is the case a lifetime counter got wrong -- and the case the daemon
11888    /// now hits routinely, since a module exits non-zero every time its
11889    /// connection to the daemon drops.
11890    #[tokio::test]
11891    async fn a_crash_older_than_the_window_frees_its_slot_for_a_later_crash() {
11892        let supervisor = Supervisor::new_for_test(
11893            Arc::new(Registry::default()),
11894            RestartPolicy::new(2, Duration::ZERO),
11895        );
11896        let runtime = supervisor.runtime_config();
11897        let spec = windowed_crash_spec("crash-across-windows");
11898        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11899
11900        for attempt in 1..=2 {
11901            assert!(matches!(
11902                on_child_exit(
11903                    &spec,
11904                    runtime.restart_policy,
11905                    &supervisor.registry,
11906                    &snapshot,
11907                    &runtime.terminal_ring,
11908                    &runtime.spawn_events,
11909                    &runtime.child_roster,
11910                    crash_exit_report(attempt),
11911                )
11912                .await,
11913                NextAction::Restart { schedule: _ }
11914            ));
11915        }
11916
11917        // The oldest crash moves out of the window; nothing else about the
11918        // module changes.
11919        update_snapshot(&snapshot, Some(&spec.module_id), |state| {
11920            age_oldest_crash_restart_out_of_window(state, runtime.restart_policy.window);
11921        })
11922        .unwrap();
11923
11924        assert!(
11925            matches!(
11926                on_child_exit(
11927                    &spec,
11928                    runtime.restart_policy,
11929                    &supervisor.registry,
11930                    &snapshot,
11931                    &runtime.terminal_ring,
11932                    &runtime.spawn_events,
11933                    &runtime.child_roster,
11934                    crash_exit_report(3),
11935                )
11936                .await,
11937                NextAction::Restart { schedule: _ }
11938            ),
11939            "a crash older than the window must not hold a budget slot"
11940        );
11941
11942        let state = lock_snapshot(&snapshot).unwrap();
11943        assert_eq!(state.state, ModuleState::Restarting);
11944        assert_eq!(
11945            state.crash_restarts.len(),
11946            2,
11947            "the aged instant is dropped and the new one takes its place"
11948        );
11949        assert_eq!(
11950            state.lifetime_restarts, 3,
11951            "the ledger counts every restart, including the ones the window forgot"
11952        );
11953    }
11954
11955    /// An operator restart hands the budget back whole, and the ledger keeps
11956    /// counting. Those are different questions -- "how close is this module to
11957    /// being stopped" and "how many times has it been replaced" -- and the
11958    /// operator action answers only the first.
11959    #[tokio::test]
11960    async fn an_operator_restart_clears_the_ring_and_leaves_the_ledger_alone() {
11961        let supervisor = Supervisor::new_for_test(
11962            Arc::new(Registry::default()),
11963            RestartPolicy::new(2, Duration::ZERO),
11964        );
11965        let runtime = supervisor.runtime_config();
11966        let spec = windowed_crash_spec("operator-cleared-budget");
11967        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11968
11969        for attempt in 1..=2 {
11970            assert!(matches!(
11971                on_child_exit(
11972                    &spec,
11973                    runtime.restart_policy,
11974                    &supervisor.registry,
11975                    &snapshot,
11976                    &runtime.terminal_ring,
11977                    &runtime.spawn_events,
11978                    &runtime.child_roster,
11979                    crash_exit_report(attempt),
11980                )
11981                .await,
11982                NextAction::Restart { schedule: _ }
11983            ));
11984        }
11985
11986        reset_restart_count(&snapshot, &spec.module_id).unwrap();
11987        {
11988            let state = lock_snapshot(&snapshot).unwrap();
11989            assert!(
11990                state.crash_restarts.is_empty(),
11991                "an operator restart returns the full budget"
11992            );
11993            assert_eq!(
11994                state.lifetime_restarts, 2,
11995                "clearing the budget must not unmake the crashes"
11996            );
11997        }
11998
11999        assert!(
12000            matches!(
12001                on_child_exit(
12002                    &spec,
12003                    runtime.restart_policy,
12004                    &supervisor.registry,
12005                    &snapshot,
12006                    &runtime.terminal_ring,
12007                    &runtime.spawn_events,
12008                    &runtime.child_roster,
12009                    crash_exit_report(3),
12010                )
12011                .await,
12012                NextAction::Restart { schedule: _ }
12013            ),
12014            "the cleared budget must be spendable again"
12015        );
12016        let state = lock_snapshot(&snapshot).unwrap();
12017        assert_eq!(state.crash_restarts.len(), 1);
12018        assert_eq!(state.lifetime_restarts, 3);
12019    }
12020
12021    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
12022    async fn severance_marker_for_a_dead_child_does_not_label_its_successor() {
12023        let severed = ProcessIdentity {
12024            pid: 41,
12025            start_time: 101,
12026        };
12027        let successor = ProcessIdentity {
12028            pid: 41,
12029            start_time: 202,
12030        };
12031        let module = module_with_recovery_snapshot(ModuleState::Running, true, 0);
12032        update_snapshot(&module.inner.snapshot, Some("recovery-snapshot"), |state| {
12033            state.pid = Some(successor.pid);
12034            state.process_start_time = Some(successor.start_time);
12035        })
12036        .unwrap();
12037        assert!(!module.record_deliberate_severance(severed).unwrap());
12038
12039        let exit_report = apply_deliberate_severance_marker(
12040            &module.inner.snapshot,
12041            Some(successor),
12042            ExitReport {
12043                kind: ExitKind::Crash,
12044                code: Some(1),
12045                signal: None,
12046                at_ms: 1,
12047            },
12048        );
12049
12050        assert_eq!(exit_report.kind, ExitKind::Crash);
12051    }
12052
12053    #[tokio::test]
12054    async fn drain_reap_marks_deliberate_severance_and_records_lifetime_without_budget() {
12055        let registry = Registry::default();
12056        let supervisor = Supervisor::new_for_test(
12057            Arc::new(Registry::default()),
12058            RestartPolicy::new(3, Duration::ZERO),
12059        );
12060        let runtime = supervisor.runtime_config();
12061        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
12062        let spec = ModuleSpec {
12063            module_id: "drain-deliberate-severance".to_string(),
12064            program: fake_aft_stub_path(),
12065            args: Vec::new(),
12066            env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
12067            reserved: false,
12068            reserved_prefixes: Vec::new(),
12069            protocol: ModuleProtocol::Subc,
12070            overlap: Default::default(),
12071        };
12072        let mut child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
12073        let process = ProcessIdentity {
12074            pid: 41,
12075            start_time: 101,
12076        };
12077        child.process_identity = Some(process);
12078        update_snapshot(&snapshot, Some(&spec.module_id), |state| {
12079            state.pid = Some(process.pid);
12080            state.process_start_time = Some(process.start_time);
12081        })
12082        .unwrap();
12083        record_deliberate_severance(&snapshot, process).unwrap();
12084
12085        drain_child_to_state(
12086            &spec.module_id,
12087            spec.protocol,
12088            // The child exits on its own; no signal may change the exit this
12089            // test classifies.
12090            StopNotice::SentOverConnection,
12091            &registry,
12092            None,
12093            &snapshot,
12094            &runtime.terminal_ring,
12095            &runtime.spawn_events,
12096            child,
12097            Duration::from_secs(1),
12098            ModuleState::Stopped,
12099            Some(false),
12100        )
12101        .await
12102        .unwrap();
12103
12104        let state = lock_snapshot(&snapshot).unwrap();
12105        assert_eq!(
12106            state.last_exit.as_ref().map(|exit| exit.kind),
12107            Some(ExitKind::DeliberateSeverance)
12108        );
12109        assert_eq!(state.lifetime_restarts, 1);
12110        assert_eq!(state.crash_restarts.len(), 0);
12111        drop(state);
12112        let history = runtime.terminal_ring.lock().unwrap().snapshot();
12113        assert_eq!(
12114            history.entries[0].exit_kind,
12115            subc_control::TerminalExitKind::DeliberateSeverance
12116        );
12117    }
12118
12119    #[tokio::test]
12120    async fn ordinary_drain_reap_does_not_record_a_lifetime_restart() {
12121        let registry = Registry::default();
12122        let supervisor = Supervisor::new_for_test(
12123            Arc::new(Registry::default()),
12124            RestartPolicy::new(3, Duration::ZERO),
12125        );
12126        let runtime = supervisor.runtime_config();
12127        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
12128        let spec = ModuleSpec {
12129            module_id: "ordinary-drain".to_string(),
12130            program: fake_aft_stub_path(),
12131            args: Vec::new(),
12132            env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
12133            reserved: false,
12134            reserved_prefixes: Vec::new(),
12135            protocol: ModuleProtocol::Subc,
12136            overlap: Default::default(),
12137        };
12138        let child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
12139
12140        drain_child_to_state(
12141            &spec.module_id,
12142            spec.protocol,
12143            // The child exits on its own; no signal may change the exit this
12144            // test classifies.
12145            StopNotice::SentOverConnection,
12146            &registry,
12147            None,
12148            &snapshot,
12149            &runtime.terminal_ring,
12150            &runtime.spawn_events,
12151            child,
12152            Duration::from_secs(1),
12153            ModuleState::Stopped,
12154            Some(false),
12155        )
12156        .await
12157        .unwrap();
12158
12159        let state = lock_snapshot(&snapshot).unwrap();
12160        assert_eq!(
12161            state.last_exit.as_ref().map(|exit| exit.kind),
12162            Some(ExitKind::Crash)
12163        );
12164        assert_eq!(state.lifetime_restarts, 0);
12165        assert_eq!(state.crash_restarts.len(), 0);
12166    }
12167
12168    #[test]
12169    fn fatal_connection_teardown_cannot_arm_a_marker_for_a_surviving_process() {
12170        // The server's generic fatal-routing branch only knows that the
12171        // connection failed; it does not know that the daemon deliberately
12172        // initiated a process-killing severance. Keep this seam explicit so a
12173        // future connection error path cannot silently reintroduce the stale
12174        // exemption that mislabels a later genuine crash.
12175        assert!(!include_str!("server.rs")
12176            .contains("router.record_deliberate_connection_severance(ctx.connection_id)"));
12177    }
12178
12179    /// The `route.closed` `drained` value must be the quiescence wait's own
12180    /// measurement (`Ok`), never invented -- except on `Err`, where there is no
12181    /// measurement at all and `false` is the one honest constant. This is the exact
12182    /// logic `begin_forwarding_drain_with` now applies before sending `route.closed`
12183    /// on every return path, including the one that used to return early via `?`
12184    /// with `route.closing` already sent and no `route.closed` ever following.
12185    #[test]
12186    fn drained_after_quiescence_wait_passes_ok_through_and_forces_false_on_err() {
12187        assert!(drained_after_quiescence_wait(&Ok(true)));
12188        assert!(!drained_after_quiescence_wait(&Ok(false)));
12189        assert!(!drained_after_quiescence_wait(&Err(
12190            SuperviseError::StatePoisoned { module_id: None }
12191        )));
12192    }
12193
12194    /// `supervise_loop`'s `wait()`-error arm now calls `record_terminal` like every
12195    /// other exit path does, so a module whose child `wait()` itself errored (e.g.
12196    /// already reaped out-of-band) still leaves a terminal record rather than none
12197    /// at all. Triggering the real `wait()` I/O error from an integration test would
12198    /// need a genuine already-reaped-child race, which is OS-specific and not
12199    /// something this suite attempts elsewhere; this test instead verifies the
12200    /// record produced for that arm end-to-end through the real `TerminalRing`, and
12201    /// the call site itself is verified by inspection to sit in that exact arm.
12202    #[test]
12203    fn wait_error_exit_report_records_a_failed_terminal_with_no_code_or_signal() {
12204        let ring = Arc::new(Mutex::new(TerminalRing::new(
12205            TerminalRingConfig::default(),
12206            0,
12207        )));
12208        record_wait_error_terminal("wait-error", &ring, &super::SpawnEventFeed::default());
12209
12210        let snapshot = ring.lock().unwrap().snapshot();
12211        assert_eq!(snapshot.entries.len(), 1);
12212        let entry = &snapshot.entries[0];
12213        assert_eq!(entry.exit_code, None);
12214        assert_eq!(entry.exit_signal, None);
12215        assert_eq!(entry.disposition, TerminalDisposition::Failed);
12216    }
12217
12218    #[test]
12219    fn wait_error_exit_path_preserves_spawn_event_density() {
12220        let feed = super::SpawnEventFeed::default();
12221        feed.configure_incarnation("wait-error-density".to_string());
12222        feed.emit_spawned("wait-error", 41, 1);
12223        let ring = Arc::new(Mutex::new(TerminalRing::new(
12224            TerminalRingConfig::default(),
12225            0,
12226        )));
12227
12228        record_wait_error_terminal("wait-error", &ring, &feed);
12229        feed.emit_spawned("after-wait-error", 42, 2);
12230
12231        let state = feed.0.lock().unwrap();
12232        let sequences = state
12233            .events
12234            .iter()
12235            .map(|event| event.cursor.seq)
12236            .collect::<Vec<_>>();
12237        assert_eq!(sequences, vec![1, 2, 3]);
12238        assert_eq!(state.events[1].kind, SpawnEventKind::Exited);
12239        assert_eq!(state.events[1].exit_code, None);
12240        assert_eq!(state.events[1].exit_signal, None);
12241    }
12242
12243    /// Pins the report's `kind` too: the wait-error arm treats an unwaitable child
12244    /// as a crash (matching `fail_snapshot`'s `Failed` disposition for this arm),
12245    /// not a clean exit it never actually observed.
12246    #[test]
12247    fn wait_error_exit_report_is_classified_as_a_crash() {
12248        assert_eq!(wait_error_exit_report().kind, ExitKind::Crash);
12249    }
12250}
12251
12252#[cfg(all(test, unix))]
12253mod health_event_tests {
12254    use super::*;
12255    use cortexkit_test_support::ScratchDir;
12256    use subc_protocol::{manifest::Concurrency, session::ModuleControlResponse};
12257
12258    struct Actor {
12259        module: SupervisedModule,
12260        registry: Arc<Registry>,
12261        forwarding: Arc<ForwardingTable>,
12262        spec: ModuleSpec,
12263        health: HealthConfig,
12264        rx: mpsc::Receiver<crate::router::OutboundFrame>,
12265        _home: ScratchDir,
12266    }
12267
12268    async fn wait_for<T>(reason: &str, mut observe: impl FnMut() -> Option<T>) -> T {
12269        // OS exec and socket readiness are real I/O. Stay runnable while waiting
12270        // for an observed condition, rather than letting paused time auto-advance
12271        // deadlines or assuming a fixed number of yields finishes the work.
12272        let deadline = std::time::Instant::now() + Duration::from_secs(10);
12273        let tick = Instant::now();
12274        loop {
12275            if let Some(value) = observe() {
12276                assert_eq!(Instant::now(), tick, "{reason} must not wait for a timer");
12277                return value;
12278            }
12279            assert!(
12280                std::time::Instant::now() < deadline,
12281                "timed out waiting for {reason}"
12282            );
12283            tokio::task::yield_now().await;
12284        }
12285    }
12286
12287    impl Actor {
12288        async fn start(protocol: ModuleProtocol, cadence: Duration) -> Self {
12289            let home = ScratchDir::new("health-events");
12290            let registry = Arc::new(Registry::default());
12291            let forwarding = Arc::new(ForwardingTable::default());
12292            let health = HealthConfig {
12293                cadence,
12294                deadline: Duration::from_secs(3600),
12295                ..HealthConfig::default()
12296            };
12297            let supervisor =
12298                Supervisor::new_for_test(registry.clone(), RestartPolicy::new(3, Duration::ZERO))
12299                    .with_forwarding(forwarding.clone())
12300                    .with_health_config(health.clone());
12301            let spec = ModuleSpec {
12302                module_id: "event-health-child".into(),
12303                program: PathBuf::from("/bin/sh"),
12304                // The process stays alive but has no real bus peer. Registrations
12305                // below exercise the same registry writes as accepted HELLOs.
12306                args: vec!["-c".into(), "exec sleep 600".into()],
12307                env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
12308                    .into_iter()
12309                    .map(|key| (key.into(), home.path().display().to_string()))
12310                    .collect(),
12311                reserved: false,
12312                reserved_prefixes: vec![],
12313                protocol,
12314                overlap: Default::default(),
12315            };
12316            let module = supervisor.spawn(spec.clone()).unwrap();
12317            let (_, rx) = mpsc::channel(8);
12318            let actor = Self {
12319                module,
12320                registry,
12321                forwarding,
12322                spec,
12323                health,
12324                rx,
12325                _home: home,
12326            };
12327            actor
12328                .wait_for_select(|checkpoint| checkpoint.generation > 0)
12329                .await;
12330            actor
12331        }
12332
12333        fn turns(&self) -> u64 {
12334            lock_snapshot(&self.module.inner.snapshot)
12335                .unwrap()
12336                .actor_turns
12337        }
12338
12339        async fn wait_for_select(
12340            &self,
12341            expected: impl Fn(&ActorSelectCheckpoint) -> bool,
12342        ) -> ActorSelectCheckpoint {
12343            wait_for("actor select checkpoint", || {
12344                let state = lock_snapshot(&self.module.inner.snapshot).unwrap();
12345                state.actor_select.filter(|checkpoint| {
12346                    checkpoint.generation == state.spawn_generation
12347                        && state.state == ModuleState::Running
12348                        && state.process_alive
12349                        && state.reported_pid().is_some()
12350                        && expected(checkpoint)
12351                })
12352            })
12353            .await
12354        }
12355
12356        fn hello(&mut self, connection: u64, health: bool) {
12357            let manifest =
12358                subc_protocol::manifest::ModuleManifest::builder(&self.spec.module_id, "1.0.0")
12359                    .protocol_ver(subc_protocol::PROTOCOL_VERSION)
12360                    .build();
12361            let connection = ConnectionId::new(connection);
12362            let (tx, rx) = mpsc::channel(8);
12363            self.rx = rx;
12364            self.forwarding
12365                .register_module_connection(
12366                    connection,
12367                    self.spec.module_id.clone(),
12368                    subc_protocol::PROTOCOL_VERSION,
12369                    Concurrency::ModuleManaged,
12370                    FrameSink::new(tx),
12371                )
12372                .unwrap();
12373            self.registry
12374                .register_with_control_ops(
12375                    manifest,
12376                    subc_protocol::PROTOCOL_VERSION,
12377                    connection,
12378                    if health {
12379                        vec![MODULE_CONTROL_OP_HEALTH_CHECK.into()]
12380                    } else {
12381                        vec![]
12382                    },
12383                )
12384                .unwrap();
12385        }
12386
12387        async fn probe(&mut self) -> crate::router::OutboundFrame {
12388            let frame = wait_for("outbound health probe", || match self.rx.try_recv() {
12389                Ok(frame) => Some(frame),
12390                Err(mpsc::error::TryRecvError::Empty) => None,
12391                Err(mpsc::error::TryRecvError::Disconnected) => panic!("health peer disconnected"),
12392            })
12393            .await;
12394            let body: Value = serde_json::from_slice(&frame.body).unwrap();
12395            assert_eq!(body["op"], MODULE_CONTROL_OP_HEALTH_CHECK);
12396            frame
12397        }
12398
12399        fn no_probe(&mut self) {
12400            assert!(matches!(
12401                self.rx.try_recv(),
12402                Err(mpsc::error::TryRecvError::Empty)
12403            ));
12404        }
12405
12406        fn answer(&self, connection: u64, frame: crate::router::OutboundFrame) {
12407            self.forwarding
12408                .complete_module_control_rpc(
12409                    ConnectionId::new(connection),
12410                    frame.header.corr,
12411                    Some(MODULE_CONTROL_OP_HEALTH_CHECK),
12412                    ModuleControlRpcOutcome::Response(ModuleControlResponse::HealthCheck {
12413                        status: HealthStatus::Ok,
12414                        detail: None,
12415                        metrics: None,
12416                    }),
12417                )
12418                .unwrap();
12419        }
12420    }
12421
12422    #[tokio::test(start_paused = true)]
12423    async fn no_health_children_park_for_sixty_seconds() {
12424        let none = Actor::start(ModuleProtocol::None, Duration::from_secs(30)).await;
12425        let unregistered = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12426        let mut unadvertised = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12427        unadvertised.hello(1, false);
12428        // Include any one-off registration wake in the budget; receiving a
12429        // no-health HELLO must not turn a parked child into a polling child.
12430        // Advance in 10 ms steps so the old poll really executes ~6,000 turns;
12431        // a single 60 s jump would only observe one expired timer.
12432        for _ in 0..6000 {
12433            tokio::time::advance(Duration::from_millis(10)).await;
12434            tokio::task::yield_now().await;
12435        }
12436        for actor in [&none, &unregistered, &unadvertised] {
12437            assert!(
12438                actor.turns() <= 5,
12439                "no-health actor woke {} times",
12440                actor.turns()
12441            );
12442            assert_eq!(actor.module.state().unwrap(), ModuleState::Running);
12443        }
12444    }
12445
12446    #[tokio::test(start_paused = true)]
12447    async fn late_hello_starts_a_due_probe_in_the_notification_tick() {
12448        // With a zero cadence the first probe is due at once, so this measures
12449        // only how fast the registration notification starts health probing,
12450        // not the normal 30-33 s wait before a first probe (checked below).
12451        let mut actor = Actor::start(ModuleProtocol::Subc, Duration::ZERO).await;
12452        let tick = Instant::now();
12453        actor.hello(1, true);
12454        actor.probe().await;
12455        assert_eq!(Instant::now(), tick, "HELLO must not wait for a poll");
12456    }
12457
12458    #[tokio::test(start_paused = true)]
12459    async fn hello_keeps_the_jittered_cadence_and_catalog_updates_do_not_reset_it() {
12460        let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12461        let hello_tick = Instant::now();
12462        actor.hello(1, true);
12463        let armed = actor
12464            .wait_for_select(|checkpoint| {
12465                checkpoint.registered_connection == Some(ConnectionId::new(1))
12466                    && checkpoint.next_probe_at.is_some()
12467            })
12468            .await;
12469        let delay = jittered_health_delay(&actor.spec.module_id, 0, actor.health.cadence);
12470        assert!((Duration::from_secs(30)..Duration::from_secs(33)).contains(&delay));
12471        let first_deadline = hello_tick + delay;
12472        assert_eq!(armed.next_probe_at, Some(first_deadline));
12473        tokio::time::advance(Duration::from_secs(29)).await;
12474        let turns = actor.turns();
12475        actor
12476            .registry
12477            .replace_catalog_for_connection(ConnectionId::new(1), vec![], None, Some(true))
12478            .unwrap();
12479        let updated = actor
12480            .wait_for_select(|checkpoint| checkpoint.turn > turns)
12481            .await;
12482        assert_eq!(
12483            updated.next_probe_at,
12484            Some(first_deadline),
12485            "catalog update must not reset cadence"
12486        );
12487        actor.no_probe();
12488        tokio::time::advance(first_deadline - Instant::now()).await;
12489        let frame = actor.probe().await;
12490        actor.answer(1, frame);
12491        let next = jittered_health_delay(&actor.spec.module_id, 1, actor.health.cadence);
12492        let next_deadline = Instant::now() + next;
12493        let rearmed = actor
12494            .wait_for_select(|checkpoint| {
12495                checkpoint
12496                    .next_probe_at
12497                    .is_some_and(|deadline| deadline > first_deadline)
12498            })
12499            .await;
12500        assert_eq!(rearmed.next_probe_at, Some(next_deadline));
12501        tokio::time::advance(next - Duration::from_nanos(1)).await;
12502        actor.no_probe();
12503        tokio::time::advance(Duration::from_nanos(1)).await;
12504        actor.probe().await;
12505    }
12506
12507    #[tokio::test(start_paused = true)]
12508    async fn disconnect_disarms_and_reconnect_rearms_health() {
12509        let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12510        actor.hello(1, true);
12511        actor
12512            .wait_for_select(|checkpoint| {
12513                checkpoint.registered_connection == Some(ConnectionId::new(1))
12514                    && checkpoint.next_probe_at.is_some()
12515            })
12516            .await;
12517        let turns = actor.turns();
12518        let tick = Instant::now();
12519        actor
12520            .registry
12521            .deregister_connection(ConnectionId::new(1))
12522            .unwrap();
12523        let disarmed = actor
12524            .wait_for_select(|checkpoint| {
12525                checkpoint.turn > turns && checkpoint.registered_connection.is_none()
12526            })
12527            .await;
12528        assert_eq!(disarmed.next_probe_at, None);
12529        assert_eq!(disarmed.wake_after, None);
12530        assert_eq!(
12531            Instant::now(),
12532            tick,
12533            "disconnect must disarm in the notification tick"
12534        );
12535        tokio::time::advance(Duration::from_secs(60)).await;
12536        actor.no_probe();
12537        let reconnect_tick = Instant::now();
12538        actor.hello(2, true);
12539        let rearmed = actor
12540            .wait_for_select(|checkpoint| {
12541                checkpoint.registered_connection == Some(ConnectionId::new(2))
12542                    && checkpoint.next_probe_at.is_some()
12543            })
12544            .await;
12545        let delay = jittered_health_delay(&actor.spec.module_id, 0, actor.health.cadence);
12546        assert_eq!(rearmed.next_probe_at, Some(reconnect_tick + delay));
12547        tokio::time::advance(delay).await;
12548        actor.probe().await;
12549    }
12550
12551    #[tokio::test(start_paused = true)]
12552    async fn reregistration_without_health_disarms_the_old_schedule() {
12553        let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12554        actor.hello(1, true);
12555        actor
12556            .wait_for_select(|checkpoint| {
12557                checkpoint.registered_connection == Some(ConnectionId::new(1))
12558                    && checkpoint.next_probe_at.is_some()
12559            })
12560            .await;
12561        actor
12562            .registry
12563            .deregister_connection(ConnectionId::new(1))
12564            .unwrap();
12565        actor.hello(2, false);
12566        let disarmed = actor
12567            .wait_for_select(|checkpoint| {
12568                checkpoint.registered_connection == Some(ConnectionId::new(2))
12569            })
12570            .await;
12571        assert_eq!(disarmed.next_probe_at, None);
12572        assert_eq!(disarmed.wake_after, None);
12573        tokio::time::advance(Duration::from_secs(60)).await;
12574        actor.no_probe();
12575    }
12576
12577    #[tokio::test(start_paused = true)]
12578    async fn restart_waits_for_its_own_hello_before_rearming_health() {
12579        let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12580        actor.hello(1, true);
12581        let armed = actor
12582            .wait_for_select(|checkpoint| {
12583                checkpoint.registered_connection == Some(ConnectionId::new(1))
12584                    && checkpoint.next_probe_at.is_some()
12585            })
12586            .await;
12587        // Simulate the old process's connection closing before the restart
12588        // tears the process down. The replacement process has started but has
12589        // not sent its HELLO, so it is not registered.
12590        actor
12591            .registry
12592            .deregister_connection(ConnectionId::new(1))
12593            .unwrap();
12594        actor.module.restart(Some(0)).await.unwrap();
12595        let parked = actor
12596            .wait_for_select(|checkpoint| {
12597                checkpoint.generation > armed.generation
12598                    && checkpoint.registered_connection.is_none()
12599                    && checkpoint.next_probe_at.is_none()
12600            })
12601            .await;
12602        assert_eq!(parked.wake_after, None);
12603        actor.rx.close();
12604        // Drain the stop notice the restart queued for the old process's connection,
12605        // so it isn't mistaken for traffic to the replacement.
12606        while actor.rx.try_recv().is_ok() {}
12607        let turns = actor.turns();
12608        tokio::time::advance(Duration::from_secs(60)).await;
12609        let after = actor.turns();
12610        eprintln!(
12611            "restart park turns: before={turns} after={after} delta={}",
12612            after - turns
12613        );
12614        assert_eq!(after, turns, "replacement must park before HELLO");
12615        let hello_tick = Instant::now();
12616        actor.hello(2, true);
12617        let rearmed = actor
12618            .wait_for_select(|checkpoint| {
12619                checkpoint.registered_connection == Some(ConnectionId::new(2))
12620                    && checkpoint.next_probe_at.is_some()
12621            })
12622            .await;
12623        let delay = jittered_health_delay(&actor.spec.module_id, 0, actor.health.cadence);
12624        assert_eq!(rearmed.next_probe_at, Some(hello_tick + delay));
12625        tokio::time::advance(delay).await;
12626        actor.probe().await;
12627    }
12628
12629    #[tokio::test(start_paused = true)]
12630    async fn rescan_adds_and_removes_http_health_without_polling() {
12631        use tokio::{
12632            io::{AsyncReadExt, AsyncWriteExt},
12633            net::TcpListener,
12634        };
12635        let mut actor = Actor::start(ModuleProtocol::None, Duration::from_secs(30)).await;
12636        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
12637        actor.health.http = Some(format!("http://{}/health", listener.local_addr().unwrap()));
12638        // One millisecond separates successive probes without a polling delay.
12639        actor.health.cadence = Duration::from_millis(1);
12640        let (started_tx, mut started_rx) = mpsc::channel(8);
12641        let peer = tokio::spawn(async move {
12642            loop {
12643                let (mut socket, _) = listener.accept().await.unwrap();
12644                let mut request = [0; 1024];
12645                assert!(socket.read(&mut request).await.unwrap() > 0);
12646                started_tx.send(()).await.unwrap();
12647                socket
12648                    .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
12649                    .await
12650                    .unwrap();
12651            }
12652        });
12653        let tick = Instant::now();
12654        let turns = actor.turns();
12655        actor
12656            .module
12657            .update_configuration(actor.spec.clone(), actor.health.clone(), None)
12658            .await
12659            .unwrap();
12660        let armed = actor
12661            .wait_for_select(|checkpoint| {
12662                checkpoint.turn > turns && checkpoint.next_probe_at.is_some()
12663            })
12664            .await;
12665        assert_eq!(armed.next_probe_at, Some(tick + Duration::from_millis(1)));
12666        assert_eq!(Instant::now(), tick);
12667        tokio::time::advance(Duration::from_millis(1)).await;
12668        let mut started = false;
12669        wait_for("successful HTTP probe", || {
12670            started |= started_rx.try_recv().is_ok();
12671            (started && actor.module.status().unwrap().health.status == SupervisorHealthStatus::Ok)
12672                .then_some(())
12673        })
12674        .await;
12675        assert!(started, "added HTTP check must start at its first deadline");
12676        assert_eq!(
12677            actor.module.status().unwrap().health.status,
12678            SupervisorHealthStatus::Ok
12679        );
12680        assert_eq!(Instant::now(), tick + Duration::from_millis(1));
12681        actor.health.http = None;
12682        let turns = actor.turns();
12683        actor
12684            .module
12685            .update_configuration(actor.spec.clone(), actor.health.clone(), None)
12686            .await
12687            .unwrap();
12688        let parked = actor
12689            .wait_for_select(|checkpoint| {
12690                checkpoint.turn > turns && checkpoint.next_probe_at.is_none()
12691            })
12692            .await;
12693        assert_eq!(parked.wake_after, None);
12694        let turns = actor.turns();
12695        tokio::time::advance(Duration::from_secs(60)).await;
12696        assert_eq!(
12697            actor.turns(),
12698            turns,
12699            "removed HTTP check must park the actor"
12700        );
12701        assert!(
12702            started_rx.try_recv().is_err(),
12703            "removed HTTP check must stay disarmed"
12704        );
12705        assert_eq!(
12706            actor.module.status().unwrap().health,
12707            ModuleHealthStatus::default()
12708        );
12709        peer.abort();
12710    }
12711}
12712
12713#[cfg(test)]
12714mod health_evidence_tests {
12715    use super::{HealthProbeError, HealthProbeEvidence};
12716    use std::collections::HashSet;
12717
12718    /// The evidential asymmetry, asserted rather than described.
12719    ///
12720    /// Exactly ONE observation is proof a module cannot serve, and the one that
12721    /// fires under CPU starvation is not it. Before the split, all fifteen
12722    /// construction sites collapsed into a single String, so a timeout carried the
12723    /// same weight as a dead lane -- which is how a healthy module was restarted
12724    /// three times in one day.
12725    #[test]
12726    fn only_a_dead_lane_is_proof_of_death() {
12727        assert!(HealthProbeError::lane_dead("gone").is_proof_of_death());
12728        // Three non-proof classes, each for a different reason: silence is
12729        // consistent with health, a bad answer proves the module ALIVE, and a
12730        // daemon-side fault never reached the module at all.
12731        assert!(!HealthProbeError::no_answer("timed out").is_proof_of_death());
12732        assert!(!HealthProbeError::bad_answer("garbage").is_proof_of_death());
12733        assert!(!HealthProbeError::misconfigured("no table").is_proof_of_death());
12734    }
12735
12736    /// Labels must be distinct, or the operator-facing distinction is cosmetic.
12737    ///
12738    /// A shared label renders two different observations identically in the line an
12739    /// operator reads after an unexplained restart -- the exact confusion this
12740    /// change removes.
12741    #[test]
12742    fn every_evidence_class_has_a_distinct_label() {
12743        let labels = [
12744            HealthProbeError::lane_dead("").label(),
12745            HealthProbeError::no_answer("").label(),
12746            HealthProbeError::bad_answer("").label(),
12747            HealthProbeError::misconfigured("").label(),
12748        ];
12749        let unique: HashSet<_> = labels.iter().collect();
12750        assert_eq!(unique.len(), labels.len(), "labels collided: {labels:?}");
12751    }
12752
12753    /// The class is additional information, not a replacement.
12754    ///
12755    /// An operator needs both "this was silence" and the specific text saying how
12756    /// long we waited; a classification that swallowed the message would trade one
12757    /// missing distinction for another.
12758    #[test]
12759    fn classification_preserves_the_original_message() {
12760        let err = HealthProbeError::no_answer("module did not answer within 5s");
12761        assert_eq!(err.to_string(), "module did not answer within 5s");
12762        assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
12763    }
12764}
12765
12766#[cfg(test)]
12767mod health_tombstone_tests {
12768    use std::{path::PathBuf, sync::Arc, time::Duration};
12769
12770    use subc_protocol::{
12771        manifest::Concurrency,
12772        session::{HealthStatus, ModuleControlResponse},
12773    };
12774    use tokio::sync::mpsc;
12775
12776    use super::{
12777        probe_module_health, HealthAction, HealthConfig, HealthProbeEvidence, ModuleProtocol,
12778        ModuleSpec, RestartPolicy, Supervisor, SupervisorRuntimeConfig,
12779    };
12780    use crate::{
12781        control::ControlHandler,
12782        forwarding::{ForwardingTable, ModuleControlRpcCompletion, ModuleControlRpcOutcome},
12783        registry::{ConnectionId, Registry},
12784        router::FrameSink,
12785    };
12786
12787    struct ProbeHarness {
12788        spec: ModuleSpec,
12789        runtime: SupervisorRuntimeConfig,
12790        forwarding: Arc<ForwardingTable>,
12791        module_connection: ConnectionId,
12792        module_rx: mpsc::Receiver<crate::router::OutboundFrame>,
12793        handler: ControlHandler,
12794        module: super::SupervisedModule,
12795    }
12796
12797    fn probe_harness() -> ProbeHarness {
12798        let registry = Arc::new(Registry::default());
12799        let forwarding = Arc::new(ForwardingTable::default());
12800        let supervisor_handle = super::SupervisorHandle::new();
12801        let health = HealthConfig {
12802            http: None,
12803            cadence: Duration::from_secs(30),
12804            deadline: Duration::from_secs(5),
12805            failure_threshold: 3,
12806            on_degraded: HealthAction::Report,
12807            on_failing: HealthAction::Report,
12808            critical: false,
12809        };
12810        let supervisor = Supervisor::new_for_test(Arc::clone(&registry), RestartPolicy::default())
12811            .with_forwarding(Arc::clone(&forwarding))
12812            .with_handle(supervisor_handle.clone())
12813            .with_health_config(health);
12814        let spec = ModuleSpec {
12815            module_id: "late-health-module".to_string(),
12816            program: PathBuf::from("disabled-module"),
12817            args: Vec::new(),
12818            env: Vec::new(),
12819            reserved: false,
12820            reserved_prefixes: Vec::new(),
12821            protocol: ModuleProtocol::Subc,
12822            overlap: Default::default(),
12823        };
12824        let module = supervisor
12825            .supervise_configured(spec.clone(), false)
12826            .unwrap();
12827        let runtime = supervisor.runtime_config();
12828        let handler = ControlHandler::with_forwarding(registry, Arc::clone(&forwarding))
12829            .with_supervisor(supervisor_handle);
12830        let module_connection = ConnectionId::new(700);
12831        let (module_tx, module_rx) = mpsc::channel(8);
12832        forwarding
12833            .register_module_connection(
12834                module_connection,
12835                spec.module_id.clone(),
12836                subc_protocol::PROTOCOL_VERSION,
12837                Concurrency::ModuleManaged,
12838                FrameSink::new(module_tx),
12839            )
12840            .unwrap();
12841
12842        ProbeHarness {
12843            spec,
12844            runtime,
12845            forwarding,
12846            module_connection,
12847            module_rx,
12848            handler,
12849            module,
12850        }
12851    }
12852
12853    async fn finish_after(
12854        harness: &mut ProbeHarness,
12855        stall: Duration,
12856    ) -> ModuleControlRpcCompletion {
12857        assert!(stall > harness.runtime.health.deadline);
12858        let deadline = harness.runtime.health.deadline;
12859        let probe = probe_module_health(&harness.spec.module_id, &harness.runtime, None);
12860        let answer = async {
12861            let frame = harness.module_rx.recv().await.expect("health.check frame");
12862            tokio::time::advance(deadline).await;
12863            tokio::task::yield_now().await;
12864            tokio::time::advance(stall - deadline).await;
12865            harness
12866                .forwarding
12867                .complete_module_control_rpc(
12868                    harness.module_connection,
12869                    frame.header.corr,
12870                    Some("health.check"),
12871                    ModuleControlRpcOutcome::Response(ModuleControlResponse::HealthCheck {
12872                        status: HealthStatus::Ok,
12873                        detail: None,
12874                        metrics: None,
12875                    }),
12876                )
12877                .unwrap()
12878        };
12879        let (probe_result, completion) = tokio::join!(probe, answer);
12880        let err = probe_result.expect_err("probe must miss its deadline");
12881        assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
12882        completion
12883    }
12884
12885    async fn time_out_without_answer(harness: &mut ProbeHarness) {
12886        let deadline = harness.runtime.health.deadline;
12887        let probe = probe_module_health(&harness.spec.module_id, &harness.runtime, None);
12888        let exhaust_deadline = async {
12889            let _frame = harness.module_rx.recv().await.expect("health.check frame");
12890            tokio::time::advance(deadline).await;
12891            tokio::task::yield_now().await;
12892        };
12893        let (probe_result, ()) = tokio::join!(probe, exhaust_deadline);
12894        let err = probe_result.expect_err("probe must miss its deadline");
12895        assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
12896    }
12897
12898    async fn run_probe_cycle(harness: &mut ProbeHarness, answer: bool) {
12899        let registry = Arc::clone(&harness.module.inner.registry);
12900        let snapshot = Arc::clone(&harness.module.inner.snapshot);
12901        let process_liveness = super::SupervisorProcessLiveness::default();
12902        let mut child = None;
12903        let cycle = super::run_health_probe_cycle(
12904            &harness.spec,
12905            &harness.runtime,
12906            &registry,
12907            &process_liveness,
12908            &snapshot,
12909            &mut child,
12910        );
12911        let peer = async {
12912            let frame = harness.module_rx.recv().await.expect("health.check frame");
12913            if answer {
12914                harness
12915                    .forwarding
12916                    .complete_module_control_rpc(
12917                        harness.module_connection,
12918                        frame.header.corr,
12919                        Some("health.check"),
12920                        ModuleControlRpcOutcome::Response(ModuleControlResponse::HealthCheck {
12921                            status: HealthStatus::Ok,
12922                            detail: None,
12923                            metrics: Some(serde_json::json!({"ready": true})),
12924                        }),
12925                    )
12926                    .unwrap();
12927            } else {
12928                tokio::time::advance(harness.runtime.health.deadline).await;
12929                tokio::task::yield_now().await;
12930            }
12931        };
12932        tokio::join!(cycle, peer);
12933    }
12934
12935    #[tokio::test(start_paused = true)]
12936    async fn unanswered_probe_is_unknown_until_threshold_and_ok_report_recovers() {
12937        let mut harness = probe_harness();
12938        // Drive the probe cycle directly with an in-memory wire peer. Stop the
12939        // disabled module's monitor so only this test owns lifecycle transitions;
12940        // no OS process is launched, and a restart is observed at scheduling.
12941        let monitor = harness.module.inner.monitor.lock().unwrap().take().unwrap();
12942        monitor.abort();
12943        let _ = monitor.await;
12944        super::update_snapshot(&harness.module.inner.snapshot, None, |state| {
12945            state.enabled = true;
12946            state.state = super::ModuleState::Running;
12947            state.process_alive = true;
12948        })
12949        .unwrap();
12950
12951        run_probe_cycle(&mut harness, true).await;
12952        assert_eq!(
12953            harness.module.status().unwrap().health.status,
12954            super::SupervisorHealthStatus::Ok
12955        );
12956
12957        for failures in 1..harness.runtime.health.failure_threshold {
12958            run_probe_cycle(&mut harness, false).await;
12959            let status = harness.module.status().unwrap();
12960            assert_eq!(status.health.status, super::SupervisorHealthStatus::Unknown);
12961            assert_eq!(status.health.consecutive_failures, failures);
12962            assert!(status.health.last_probe_ms.is_some());
12963            assert!(status.health.detail.unwrap().starts_with("[no-answer]"));
12964            assert!(status.health.metrics.is_none());
12965            assert_eq!(status.state, super::ModuleState::Running);
12966            assert!(status.process_alive);
12967            assert_eq!(status.restart_count, 0);
12968            assert_eq!(status.lifetime_restarts, 0);
12969            assert!(status.health.last_action.is_none());
12970            assert!(harness.runtime.scheduled_respawn.lock().unwrap().is_none());
12971        }
12972
12973        run_probe_cycle(&mut harness, true).await;
12974        let recovered = harness.module.status().unwrap();
12975        assert_eq!(recovered.health.status, super::SupervisorHealthStatus::Ok);
12976        assert_eq!(recovered.health.consecutive_failures, 0);
12977        assert!(recovered.health.detail.is_none());
12978        assert_eq!(
12979            recovered.health.metrics,
12980            Some(serde_json::json!({"ready": true}))
12981        );
12982        assert_eq!(recovered.lifetime_restarts, 0);
12983
12984        for failures in 1..=harness.runtime.health.failure_threshold {
12985            run_probe_cycle(&mut harness, false).await;
12986            let status = harness.module.status().unwrap();
12987            assert_eq!(status.health.consecutive_failures, failures);
12988            if failures < harness.runtime.health.failure_threshold {
12989                assert_eq!(status.health.status, super::SupervisorHealthStatus::Unknown);
12990                assert_eq!(status.state, super::ModuleState::Running);
12991                assert_eq!(status.lifetime_restarts, 0);
12992                assert!(harness.runtime.scheduled_respawn.lock().unwrap().is_none());
12993            } else {
12994                assert_eq!(
12995                    status.health.status,
12996                    super::SupervisorHealthStatus::Unresponsive
12997                );
12998                assert_eq!(status.state, super::ModuleState::Restarting);
12999                assert_eq!(status.restart_count, 1);
13000                assert_eq!(status.lifetime_restarts, 1);
13001                assert_eq!(status.health.last_action.as_deref(), Some("restart"));
13002                assert!(harness.runtime.scheduled_respawn.lock().unwrap().is_some());
13003            }
13004        }
13005    }
13006
13007    #[tokio::test(start_paused = true)]
13008    async fn late_health_answers_record_start_anchored_latency_for_two_stalls() {
13009        let mut harness = probe_harness();
13010
13011        let first = finish_after(&mut harness, Duration::from_secs(8)).await;
13012        let first_latency = match &first {
13013            ModuleControlRpcCompletion::LateHealthAnswer { latency, .. } => *latency,
13014            other => panic!("late answer was not retained: {other:?}"),
13015        };
13016        assert!(harness.handler.observe_module_control_completion(first));
13017
13018        let second = finish_after(&mut harness, Duration::from_secs(11)).await;
13019        let second_latency = match &second {
13020            ModuleControlRpcCompletion::LateHealthAnswer { latency, .. } => *latency,
13021            other => panic!("late answer was not retained: {other:?}"),
13022        };
13023        assert!(harness.handler.observe_module_control_completion(second));
13024
13025        assert_eq!(first_latency, Duration::from_secs(8));
13026        assert_eq!(
13027            second_latency - first_latency,
13028            Duration::from_secs(3),
13029            "latency must grow linearly with the additional stall"
13030        );
13031        let health = harness.module.status().unwrap().health;
13032        assert_eq!(health.late_answer_count, 2);
13033        assert_eq!(health.last_late_answer_latency_ms, Some(11_000));
13034    }
13035
13036    /// A module that answers every probe late must never march to the kill
13037    /// threshold: the late answer proves it is alive, so it must clear the miss
13038    /// streak the timeout recorded. Without the reset, a CPU-starved module
13039    /// that serves every probe seconds past the deadline accumulates
13040    /// `consecutive_failures` to the threshold and is killed — the exact
13041    /// sequence from the 2026-08-14 aft disable, where the daemon logged
13042    /// "proves the module is alive" five times while counting five misses.
13043    #[tokio::test(start_paused = true)]
13044    async fn late_answer_clears_the_consecutive_failure_streak() {
13045        let mut harness = probe_harness();
13046
13047        // Timeout recorded first: the probe path saw no answer in time.
13048        time_out_without_answer(&mut harness).await;
13049        harness
13050            .module
13051            .record_health_probe_failure_for_test("[no-answer] test miss")
13052            .unwrap();
13053        assert_eq!(
13054            harness.module.status().unwrap().health.consecutive_failures,
13055            1,
13056            "precondition: the miss must be on the streak before the late answer"
13057        );
13058
13059        // The stalled reply then lands: proof of life.
13060        let late = finish_after(&mut harness, Duration::from_secs(9)).await;
13061        assert!(matches!(
13062            late,
13063            ModuleControlRpcCompletion::LateHealthAnswer { .. }
13064        ));
13065        assert!(harness.handler.observe_module_control_completion(late));
13066
13067        let health = harness.module.status().unwrap().health;
13068        assert_eq!(
13069            health.consecutive_failures, 0,
13070            "a late answer is an answer: the streak must reset"
13071        );
13072        assert_eq!(health.late_answer_count, 1);
13073    }
13074
13075    #[tokio::test(start_paused = true)]
13076    async fn repeated_serial_probe_cycles_keep_one_tombstone_per_endpoint() {
13077        let mut harness = probe_harness();
13078
13079        for _ in 0..20 {
13080            time_out_without_answer(&mut harness).await;
13081            assert_eq!(
13082                harness.forwarding.health_probe_tombstone_count().unwrap(),
13083                1
13084            );
13085        }
13086    }
13087}
13088
13089#[cfg(test)]
13090mod child_env_tests {
13091    #[cfg(windows)]
13092    use super::{
13093        clear_current_process_facts, observed_launch_nonce_source, ModuleState, SupervisorSnapshot,
13094    };
13095    #[cfg(windows)]
13096    use std::sync::{Arc, OnceLock};
13097    #[cfg(windows)]
13098    #[test]
13099    fn windows_wire_spawn_prepares_pipe_and_keeps_phase_one_env() {
13100        let spec = spec(Vec::new());
13101        let mut command = Command::new("unused");
13102        let handoff = apply_wire_spawn_args(&mut command, &spec, None, None)
13103            .unwrap()
13104            .unwrap();
13105        let name = command
13106            .as_std()
13107            .get_envs()
13108            .find(|(key, _)| *key == OsStr::new(subc_os::launch_nonce::LAUNCH_NONCE_PIPE_ENV))
13109            .and_then(|(_, value)| value)
13110            .unwrap();
13111        assert_eq!(name, OsStr::new(handoff.name()));
13112        assert!(handoff.name().starts_with(r"\\.\pipe\subc-launch-"));
13113        assert!(command.as_std().get_envs().any(|(key, value)| key
13114            == OsStr::new(subc_os::LAUNCH_NONCE_PIPE_FALLBACK_ENV)
13115            && value == Some(OsStr::new("env"))));
13116        assert!(command
13117            .as_std()
13118            .get_envs()
13119            .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some()));
13120    }
13121
13122    #[cfg(windows)]
13123    #[test]
13124    fn windows_source_reporting_tracks_consumption_and_resets_per_spawn() {
13125        let mut snapshot = SupervisorSnapshot::new(ModuleState::Running, true);
13126        snapshot.process_alive = true;
13127        snapshot.spawned_protocol = Some(ModuleProtocol::Subc);
13128        assert_eq!(
13129            observed_launch_nonce_source(&snapshot).as_deref(),
13130            Some("env")
13131        );
13132        let consumed = Arc::new(OnceLock::new());
13133        snapshot.pipe_consumed = Some(consumed.clone());
13134        assert_eq!(
13135            observed_launch_nonce_source(&snapshot).as_deref(),
13136            Some("env")
13137        );
13138        consumed.set(()).unwrap();
13139        assert_eq!(
13140            observed_launch_nonce_source(&snapshot).as_deref(),
13141            Some("pipe")
13142        );
13143        snapshot.pipe_consumed = Some(Arc::new(OnceLock::new()));
13144        assert_eq!(
13145            observed_launch_nonce_source(&snapshot).as_deref(),
13146            Some("env")
13147        );
13148        clear_current_process_facts(&mut snapshot);
13149        assert_eq!(observed_launch_nonce_source(&snapshot), None);
13150    }
13151    use super::{
13152        apply_child_env, apply_spawn_role, apply_wire_spawn_args, ModuleProtocol, ModuleSpec,
13153        SpawnRole, SupervisorHandle, SPAWN_ROLE_SWAP_CANDIDATE, SUBC_ARG, SUBC_LAUNCH_NONCE_ENV,
13154        SUBC_MODULE_ID_ENV, SUBC_SPAWN_ROLE_ENV,
13155    };
13156    use std::{ffi::OsStr, path::PathBuf};
13157    use tokio::process::Command;
13158
13159    fn spec(env: Vec<(String, String)>) -> ModuleSpec {
13160        ModuleSpec {
13161            module_id: "env-plan".to_string(),
13162            program: PathBuf::from("/nonexistent"),
13163            args: Vec::new(),
13164            env,
13165            reserved: false,
13166            reserved_prefixes: Vec::new(),
13167            protocol: ModuleProtocol::Subc,
13168            overlap: Default::default(),
13169        }
13170    }
13171
13172    /// Ambient `CK_LOG` is REMOVED for an unconfigured module, and a configured
13173    /// one still gets its own.
13174    ///
13175    /// This is the narrow goal `env_clear()` was reached for, and the reason the
13176    /// fix is `env_remove` rather than deleting the line: an operator's ambient
13177    /// filter silently becoming an unconfigured module's log level is a real
13178    /// defect, just a much smaller one than clearing the environment.
13179    ///
13180    /// Asserted on the command plan rather than a spawned child because proving
13181    /// the ABSENCE of an inherited variable needs the parent's environment
13182    /// mutated, and `forbid(unsafe_code)` refuses that. `get_envs()` reports a
13183    /// removal as `(key, None)`, which is exactly the distinction wanted: not
13184    /// "absent because nobody set it" but "explicitly unset for the child".
13185    #[test]
13186    fn ambient_ck_log_is_removed_and_a_configured_one_survives() {
13187        let mut command = Command::new("/nonexistent");
13188        apply_child_env(&mut command, &spec(Vec::new()));
13189        let removed = command
13190            .as_std()
13191            .get_envs()
13192            .any(|(key, value)| key == OsStr::new("CK_LOG") && value.is_none());
13193        assert!(
13194            removed,
13195            "ambient CK_LOG must be explicitly removed for an unconfigured module"
13196        );
13197
13198        let mut configured = Command::new("/nonexistent");
13199        apply_child_env(
13200            &mut configured,
13201            &spec(vec![("CK_LOG".to_string(), "debug".to_string())]),
13202        );
13203        let effective = configured
13204            .as_std()
13205            .get_envs()
13206            .filter(|(key, _)| *key == OsStr::new("CK_LOG"))
13207            .last()
13208            .map(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()));
13209        assert_eq!(
13210            effective,
13211            Some(Some("debug".to_string())),
13212            "a module's configured CK_LOG must survive the ambient removal"
13213        );
13214    }
13215
13216    /// A `protocol: "none"` spawn carries NO `--subc` argument and NO launch
13217    /// nonce; a subc-wire spawn carries both. Asserted on the command plan for
13218    /// the same reason as the CK_LOG test above.
13219    ///
13220    /// The argument is the load-bearing half: a stock binary exits on an
13221    /// unknown flag before it listens, so with `--subc` appended the mode
13222    /// could not supervise the one process it exists for. Found by the first
13223    /// conformance run (nats-server: `flag provided but not defined: -subc`).
13224    #[test]
13225    fn protocol_none_spawn_carries_no_subc_argument_and_no_nonce() {
13226        let connection_file = std::path::Path::new("/run/subc-connection.json");
13227        let handle = SupervisorHandle::new();
13228
13229        let mut none_spec = spec(Vec::new());
13230        none_spec.protocol = ModuleProtocol::None;
13231        let mut none = Command::new("/nonexistent");
13232        let none_handoff =
13233            apply_wire_spawn_args(&mut none, &none_spec, Some(connection_file), Some(&handle))
13234                .expect("protocol-none spawn args apply");
13235        assert!(
13236            none_handoff.is_none(),
13237            "protocol:none spawn must not receive a nonce descriptor"
13238        );
13239        #[cfg(windows)]
13240        assert!(!none.as_std().get_envs().any(|(key, value)| key
13241            == OsStr::new(subc_os::LAUNCH_NONCE_PIPE_FALLBACK_ENV)
13242            && value.is_some()));
13243        assert!(
13244            !none.as_std().get_envs().any(|(key, value)| key
13245                == OsStr::new(subc_os::LAUNCH_NONCE_FD_ENV)
13246                && value.is_some()),
13247            "protocol:none spawn must not name a nonce descriptor"
13248        );
13249        let none_args: Vec<String> = none
13250            .as_std()
13251            .get_args()
13252            .map(|a| a.to_string_lossy().into_owned())
13253            .collect();
13254        assert!(
13255            !none_args.iter().any(|a| a == SUBC_ARG),
13256            "protocol:none argv must not carry --subc; got {none_args:?}"
13257        );
13258        let none_has_nonce = none
13259            .as_std()
13260            .get_envs()
13261            .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some());
13262        assert!(
13263            !none_has_nonce,
13264            "protocol:none spawn must not receive a launch nonce"
13265        );
13266        let none_has_module_id = none
13267            .as_std()
13268            .get_envs()
13269            .any(|(key, value)| key == OsStr::new(SUBC_MODULE_ID_ENV) && value.is_some());
13270        assert!(
13271            none_has_module_id,
13272            "SUBC_MODULE_ID is inert and stays on every path"
13273        );
13274        assert!(
13275            handle.spawn_nonce(&none_spec.module_id).is_none(),
13276            "no nonce record for a process that will never present one"
13277        );
13278
13279        // Control: the subc-wire path is unchanged by the branch above.
13280        let wire_spec = spec(Vec::new());
13281        let mut wire = Command::new("/nonexistent");
13282        let wire_handoff =
13283            apply_wire_spawn_args(&mut wire, &wire_spec, Some(connection_file), Some(&handle))
13284                .expect("subc-wire spawn args apply");
13285        let wire_fd_env = wire
13286            .as_std()
13287            .get_envs()
13288            .find(|(key, _)| *key == OsStr::new(subc_os::LAUNCH_NONCE_FD_ENV))
13289            .and_then(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()));
13290        #[cfg(unix)]
13291        assert_eq!(
13292            wire_fd_env,
13293            Some(wire_handoff.expect("a descriptor handoff").fd_env_value()),
13294            "a subc-wire spawn names the pipe it will receive at descriptor 3"
13295        );
13296        #[cfg(windows)]
13297        assert!(wire_handoff.is_some() && wire_fd_env.is_none());
13298        #[cfg(not(any(unix, windows)))]
13299        assert!(wire_handoff.is_none() && wire_fd_env.is_none());
13300        let wire_args: Vec<String> = wire
13301            .as_std()
13302            .get_args()
13303            .map(|a| a.to_string_lossy().into_owned())
13304            .collect();
13305        assert_eq!(
13306            wire_args,
13307            vec![
13308                SUBC_ARG.to_string(),
13309                connection_file.to_string_lossy().into_owned()
13310            ],
13311            "a subc-wire spawn still carries --subc <path>"
13312        );
13313        assert_eq!(
13314            wire.as_std()
13315                .get_envs()
13316                .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some()),
13317            !cfg!(unix),
13318            "only Windows supplies the environment nonce"
13319        );
13320        assert!(handle.spawn_nonce(&wire_spec.module_id).is_some());
13321    }
13322
13323    /// A plain spawn EXPLICITLY REMOVES the spawn role, even when the module's
13324    /// spec tries to set it; only a swap candidate carries it.
13325    ///
13326    /// "Set it only on candidates" is not enough, because spawn applies the
13327    /// spec's env verbatim and the daemon's own environment is inherited: either
13328    /// could hand a plain restart the swap role, and a module reading it would
13329    /// warm on its long swap budget while callers wait. Asserted as an explicit
13330    /// removal (`(key, None)`), not mere absence, for the reason the `CK_LOG`
13331    /// test above gives.
13332    #[test]
13333    fn plain_spawn_removes_the_spawn_role_even_when_the_spec_sets_it() {
13334        let role = |command: &Command| {
13335            command
13336                .as_std()
13337                .get_envs()
13338                .filter(|(key, _)| *key == OsStr::new(SUBC_SPAWN_ROLE_ENV))
13339                .last()
13340                .map(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()))
13341        };
13342        let forged = spec(vec![(
13343            SUBC_SPAWN_ROLE_ENV.to_string(),
13344            SPAWN_ROLE_SWAP_CANDIDATE.to_string(),
13345        )]);
13346
13347        let mut plain = Command::new("/nonexistent");
13348        apply_child_env(&mut plain, &forged);
13349        apply_spawn_role(&mut plain, SpawnRole::Plain);
13350        assert_eq!(
13351            role(&plain),
13352            Some(None),
13353            "a plain spawn must remove SUBC_SPAWN_ROLE, whatever the spec says"
13354        );
13355
13356        let mut candidate = Command::new("/nonexistent");
13357        apply_child_env(&mut candidate, &spec(Vec::new()));
13358        apply_spawn_role(&mut candidate, SpawnRole::SwapCandidate);
13359        assert_eq!(
13360            role(&candidate),
13361            Some(Some(SPAWN_ROLE_SWAP_CANDIDATE.to_string()))
13362        );
13363    }
13364
13365    /// Daemon-private capture retention keys never reach the child.
13366    ///
13367    /// cortexkit-log exposes retention as a Rust struct with no environment
13368    /// names, so these entries are supervisor metadata. Passing them through
13369    /// would invent a public child-process contract by accident.
13370    #[test]
13371    fn daemon_private_capture_keys_are_not_passed_to_the_child() {
13372        let mut command = Command::new("/nonexistent");
13373        apply_child_env(
13374            &mut command,
13375            &spec(vec![
13376                (super::CAPTURE_KEEP_ENV.to_string(), "5".to_string()),
13377                ("KEPT".to_string(), "yes".to_string()),
13378            ]),
13379        );
13380        let keys: Vec<String> = command
13381            .as_std()
13382            .get_envs()
13383            .filter(|(_, value)| value.is_some())
13384            .map(|(key, _)| key.to_string_lossy().into_owned())
13385            .collect();
13386        assert!(keys.contains(&"KEPT".to_string()), "got {keys:?}");
13387        assert!(
13388            !keys.contains(&super::CAPTURE_KEEP_ENV.to_string()),
13389            "daemon-private capture key leaked to the child: {keys:?}"
13390        );
13391    }
13392}
13393
13394#[cfg(test)]
13395mod jitter_tests {
13396    use super::jittered_health_delay;
13397    use std::{collections::HashSet, time::Duration};
13398
13399    /// Module ids drawn from a real fleet, so the dispersal claim is about names
13400    /// that actually occur rather than invented ones.
13401    ///
13402    /// This is a SAMPLE, not a registry: the property under test is that distinct
13403    /// ids disperse, which holds for any set of distinct strings. Several entries
13404    /// are already historical (modules get renamed), and that costs nothing here --
13405    /// but it means a reader must not mistake this for the live module set, and a
13406    /// rename sweep will match it without there being anything to change.
13407    const FLEET: [&str; 14] = [
13408        "aft",
13409        "alfonso-core",
13410        "magic-context",
13411        "broca",
13412        "thalamus",
13413        "quota",
13414        "engram",
13415        "plexus",
13416        "cerebellum",
13417        "astrocyte",
13418        "synapse",
13419        "subc-mcp",
13420        "cortexkit-credentials",
13421        "subc-federation",
13422    ];
13423
13424    /// Probes must not converge after a fleet-wide restart.
13425    ///
13426    /// This is the property the jitter exists for: every module reconnects at
13427    /// once, and without dispersal all fourteen would then probe on the same
13428    /// tick forever. Nothing failed visibly when this went untested -- a
13429    /// convergent fleet still probes correctly, just in a burst, so the symptom
13430    /// is a periodic load spike that looks like whatever else is running.
13431    #[test]
13432    fn probe_delays_disperse_across_the_fleet() {
13433        let cadence = Duration::from_secs(30);
13434        let delays: HashSet<Duration> = FLEET
13435            .iter()
13436            .map(|id| jittered_health_delay(id, 0, cadence))
13437            .collect();
13438        assert_eq!(
13439            delays.len(),
13440            FLEET.len(),
13441            "every supervised module must land on its own probe offset"
13442        );
13443    }
13444
13445    /// The offset may only ever DELAY a probe, never bring it forward.
13446    ///
13447    /// A delay below the cadence would probe a module more often than
13448    /// configured, which is the opposite of what an operator asked for and
13449    /// would tighten the failure budget without anyone changing it.
13450    #[test]
13451    fn jitter_only_delays_and_stays_within_one_tenth_of_cadence() {
13452        let cadence = Duration::from_secs(30);
13453        let span = cadence / 10;
13454        for id in FLEET {
13455            for probe_index in 0..8 {
13456                let delay = jittered_health_delay(id, probe_index, cadence);
13457                assert!(
13458                    delay >= cadence,
13459                    "{id}#{probe_index}: jitter must not shorten the cadence"
13460                );
13461                assert!(
13462                    delay < cadence + span,
13463                    "{id}#{probe_index}: jitter must stay inside one tenth of the cadence"
13464                );
13465            }
13466        }
13467    }
13468
13469    /// A module keeps its offset across daemon restarts.
13470    ///
13471    /// The delay is derived rather than randomised precisely so a restart does
13472    /// not re-roll every module into a fresh chance of collision. A random
13473    /// source would satisfy the dispersal test above and quietly lose this.
13474    #[test]
13475    fn a_module_offset_is_stable_across_restarts() {
13476        let cadence = Duration::from_secs(30);
13477        for id in FLEET {
13478            assert_eq!(
13479                jittered_health_delay(id, 0, cadence),
13480                jittered_health_delay(id, 0, cadence),
13481                "{id}: the same module and probe index must produce the same offset"
13482            );
13483        }
13484    }
13485
13486    /// A zero cadence disables probing rather than producing a busy loop.
13487    #[test]
13488    fn zero_cadence_yields_zero_delay() {
13489        assert_eq!(
13490            jittered_health_delay("aft", 0, Duration::ZERO),
13491            Duration::ZERO
13492        );
13493    }
13494}
13495
13496#[cfg(all(test, target_os = "linux"))]
13497mod cgroup_placement_tests {
13498    use super::{
13499        apply_cgroup_placement, remove_module_cgroup, ModuleProtocol, ModuleSpec, SuperviseError,
13500        SupervisedChild,
13501    };
13502    use crate::stderr_tail::{StderrRing, StderrTailConfig};
13503    use cortexkit_test_support::ScratchDir;
13504    use std::{
13505        fs, io,
13506        path::{Path, PathBuf},
13507        sync::{Arc, Mutex},
13508    };
13509    use tokio::process::Command;
13510
13511    #[tokio::test]
13512    async fn unique_spawn_cgroups_clean_up_across_restarts_and_shutdown() {
13513        use super::*;
13514        let dir = ScratchDir::new("unique-spawn-cgroups");
13515        let root = PathBuf::from(format!(
13516            "/sys/fs/cgroup/subc-unique-test-{}-{}",
13517            std::process::id(),
13518            unix_ms_now()
13519        ));
13520        if let Err(error) = fs::create_dir(&root) {
13521            assert!(
13522                std::env::var_os("SUBC_REQUIRE_CGROUP_TEST").is_none(),
13523                "required cgroup test cannot execute: {error}"
13524            );
13525            eprintln!("SKIP unique_spawn_cgroups_clean_up_across_restarts_and_shutdown: {error}");
13526            return;
13527        }
13528        let placement = subc_cgroup::prepare_at(&root).unwrap().unwrap();
13529        let group_count = || {
13530            fs::read_dir(root.join("subc-modules"))
13531                .unwrap()
13532                .map(|entry| entry.unwrap().file_type().unwrap())
13533                .filter(|kind| kind.is_dir())
13534                .count()
13535        };
13536        let supervisor =
13537            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
13538                .with_cgroup_placement(Some(placement.clone()));
13539        let runtime = supervisor.runtime_config();
13540        let mut spec = ModuleSpec {
13541            module_id: "unique-spawn".into(),
13542            program: PathBuf::from("/bin/sleep"),
13543            args: vec!["60".into()],
13544            env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
13545                .into_iter()
13546                .map(|key| (key.into(), dir.to_string_lossy().into_owned()))
13547                .collect(),
13548            reserved: false,
13549            reserved_prefixes: vec![],
13550            protocol: ModuleProtocol::None,
13551            overlap: Default::default(),
13552        };
13553        let spawn = |spec: &ModuleSpec| {
13554            spawn_child(
13555                spec,
13556                None,
13557                None,
13558                &runtime.stderr_ring,
13559                None,
13560                &runtime.child_roster,
13561                Some(&placement),
13562            )
13563            .unwrap()
13564        };
13565        let mut live = spawn(&spec);
13566        for _ in 0..3 {
13567            // A new process can enter the old slot while retirement is pending.
13568            let next = spawn(&spec);
13569            assert_ne!(live.module_id, next.module_id);
13570            live.start_kill().unwrap();
13571            live.wait().await.unwrap();
13572            live = next;
13573            assert!(
13574                live.child.try_wait().unwrap().is_none(),
13575                "retiring the old slot must not kill the replacement"
13576            );
13577            assert_eq!(
13578                group_count(),
13579                1,
13580                "only the live spawn's cgroup should remain"
13581            );
13582        }
13583        supervisor.begin_daemon_shutdown();
13584        let reap = tokio::spawn(async move {
13585            live.wait().await.unwrap();
13586        });
13587        supervisor
13588            .end_children_for_daemon_shutdown(false, std::future::pending())
13589            .await;
13590        reap.await.unwrap();
13591        assert_eq!(group_count(), 0);
13592        // A normal exit uses the same tree-cleanup path as a killed spawn.
13593        spec.program = PathBuf::from("/bin/true");
13594        spec.args.clear();
13595        let fresh_roster = ChildRoster::default();
13596        let mut short = spawn_child(
13597            &spec,
13598            None,
13599            None,
13600            &runtime.stderr_ring,
13601            None,
13602            &fresh_roster,
13603            Some(&placement),
13604        )
13605        .unwrap();
13606        short.wait().await.unwrap();
13607        assert_eq!(group_count(), 0);
13608        spec.module_id = "_".repeat(255);
13609        let mut long_id = spawn_child(
13610            &spec,
13611            None,
13612            None,
13613            &runtime.stderr_ring,
13614            None,
13615            &fresh_roster,
13616            Some(&placement),
13617        )
13618        .unwrap();
13619        long_id.wait().await.unwrap();
13620        assert_eq!(
13621            group_count(),
13622            0,
13623            "valid long module IDs must not exceed cgroup NAME_MAX"
13624        );
13625        fs::remove_dir(root.join("subc-modules")).unwrap();
13626        fs::remove_dir(root).unwrap();
13627        eprintln!("EXECUTED unique_spawn_cgroups_clean_up_across_restarts_and_shutdown");
13628    }
13629
13630    #[test]
13631    fn failed_parent_cgroup_open_is_a_cgroup_supervision_error() {
13632        let path = Path::new("/definitely-missing-subc-cgroup");
13633        let mut command = Command::new("true");
13634        let error = apply_cgroup_placement(
13635            &mut command,
13636            &ModuleSpec {
13637                module_id: "broken-cgroup".to_string(),
13638                program: PathBuf::from("true"),
13639                args: Vec::new(),
13640                env: Vec::new(),
13641                reserved: false,
13642                reserved_prefixes: Vec::new(),
13643                protocol: ModuleProtocol::Subc,
13644                overlap: Default::default(),
13645            },
13646            path,
13647        )
13648        .expect_err("a parent cgroup open failure must reject the supervised spawn");
13649        let reason = error.to_string();
13650
13651        assert!(
13652            matches!(error, SuperviseError::Cgroup { .. }),
13653            "parent cgroup open must be reported as a cgroup supervision error: {reason}"
13654        );
13655        assert!(
13656            reason.contains("/definitely-missing-subc-cgroup/cgroup.procs"),
13657            "parent cgroup open failure must name cgroup.procs: {reason}"
13658        );
13659    }
13660
13661    #[tokio::test]
13662    async fn reaping_a_child_removes_its_empty_module_cgroup() {
13663        let root = ScratchDir::new("supervisor-reap-cgroup");
13664        fs::write(root.join("cgroup.procs"), b"").expect("write scratch cgroup marker");
13665        let placement = subc_cgroup::prepare_at(&root)
13666            .expect("prepare scratch cgroup root")
13667            .expect("scratch root has a cgroup.procs marker");
13668        let module_id = "reaped-module";
13669        let module = placement
13670            .module_path(module_id)
13671            .expect("create scratch module cgroup");
13672        let child = Command::new("true")
13673            .env("XDG_DATA_HOME", root.path())
13674            .env("XDG_RUNTIME_DIR", root.path())
13675            .env("XDG_CONFIG_HOME", root.path())
13676            .spawn()
13677            .expect("spawn short-lived child");
13678        let pid = child.id().expect("spawned child has pid");
13679        let mut child = SupervisedChild {
13680            child,
13681            protocol: ModuleProtocol::Subc,
13682            module_id: module_id.to_string(),
13683            cgroup_placement: Some(placement),
13684            stdout_pump: None,
13685            stderr_pump: None,
13686            stderr_ring: Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default()))),
13687            spawned_at_ms: 0,
13688            spawned_from: PathBuf::from("true"),
13689            spawned_file_identity: None,
13690            process_start_time: None,
13691            process_identity: None,
13692            pid,
13693            roster_guard: None,
13694            #[cfg(target_os = "macos")]
13695            privacy_exec: None,
13696            spawn_failure: None,
13697        };
13698
13699        child.wait().await.expect("reap short-lived child");
13700
13701        assert!(
13702            !module.exists(),
13703            "reaping the supervised child must remove its empty cgroup"
13704        );
13705    }
13706
13707    #[test]
13708    fn non_empty_cgroup_removal_is_reported_without_blocking_teardown() {
13709        let root = ScratchDir::new("supervisor-non-empty-cgroup");
13710        fs::write(root.join("cgroup.procs"), b"").expect("write scratch cgroup marker");
13711        let placement = subc_cgroup::prepare_at(&root)
13712            .expect("prepare scratch cgroup root")
13713            .expect("scratch root has a cgroup.procs marker");
13714        let module = placement
13715            .module_path("surviving-module")
13716            .expect("create scratch module cgroup");
13717        fs::write(module.join("surviving-process"), b"still present")
13718            .expect("make scratch cgroup non-empty");
13719        let (logs, _guard) = crate::router::test_log::log_capture(tracing::Level::WARN);
13720
13721        remove_module_cgroup(&placement, "surviving-module");
13722
13723        let logs = crate::router::test_log::captured_logs(&logs);
13724        assert!(
13725            module.exists(),
13726            "failed removal must leave the cgroup intact"
13727        );
13728        assert!(
13729            logs.contains("could not remove module cgroup after process exit; continuing teardown")
13730                && logs.contains("surviving-module"),
13731            "best-effort removal must report the failure without returning it: {logs}"
13732        );
13733    }
13734
13735    #[test]
13736    fn cgroup_pre_exec_spawn_failure_names_the_cgroup_path() {
13737        let cgroup_path = PathBuf::from("/sys/fs/cgroup/subc-modules/broken-module");
13738        let reason = SuperviseError::Spawn {
13739            program: PathBuf::from("/bin/true"),
13740            source: io::Error::from_raw_os_error(13),
13741            cgroup_path: Some(cgroup_path.clone()),
13742        }
13743        .to_string();
13744
13745        assert!(
13746            reason.contains(&cgroup_path.display().to_string()),
13747            "a pre_exec spawn failure must name the cgroup path: {reason}"
13748        );
13749    }
13750}
13751
13752#[cfg(test)]
13753mod spawn_subscriber_lag_tests {
13754    use super::*;
13755
13756    /// A subscriber whose connection stops draining is dropped once its frame
13757    /// channel fills. The client must learn that from a terminal Error frame
13758    /// after the frames already queued for it, not from a stream that simply
13759    /// goes quiet.
13760    #[tokio::test]
13761    async fn lagged_spawn_subscriber_receives_a_terminal_lagged_error_after_its_queued_frames() {
13762        let feed = SpawnEventFeed::default();
13763        feed.configure_incarnation("lag-incarnation".to_string());
13764        // A one-slot connection queue that nobody reads until the emits are
13765        // done: the forwarder parks on it and the subscriber channel fills.
13766        let (tx, mut rx) = mpsc::channel(1);
13767        feed.subscribe(ConnectionId::new(1), 7, 1, None, FrameSink::new(tx))
13768            .expect("subscribe");
13769        let emitted = SPAWN_SUBSCRIBER_BUFFER + 16;
13770        for index in 0..emitted {
13771            feed.emit_spawned(&format!("lag-module-{index}"), 1000, 0);
13772            // Let the forwarder take what it can so the fill point is the
13773            // subscriber channel, not a scheduling accident.
13774            tokio::task::yield_now().await;
13775        }
13776        assert_eq!(
13777            feed.subscriber_count(),
13778            0,
13779            "the lagged subscriber must be removed"
13780        );
13781
13782        let mut data = Vec::new();
13783        let mut last = None;
13784        loop {
13785            let next = tokio::time::timeout(Duration::from_secs(5), rx.recv())
13786                .await
13787                .expect("the forwarder must finish once the subscriber is dropped");
13788            let Some(outbound) = next else { break };
13789            let frame = outbound.frame;
13790            if frame.header.ty == FrameType::StreamData {
13791                assert!(last.is_none(), "no data may follow the terminal frame");
13792                let event: SpawnEvent = serde_json::from_slice(&frame.body).unwrap();
13793                data.push(event.cursor.seq);
13794            } else {
13795                assert!(last.is_none(), "exactly one terminal frame");
13796                last = Some(frame);
13797            }
13798        }
13799        assert!(!data.is_empty(), "queued frames drain before the terminal");
13800        for pair in data.windows(2) {
13801            assert_eq!(
13802                pair[1],
13803                pair[0] + 1,
13804                "queued frames arrive dense and in order"
13805            );
13806        }
13807        let terminal = last.expect("a lagged subscriber must receive a terminal frame");
13808        assert_eq!(terminal.header.ty, FrameType::Error);
13809        assert_eq!(terminal.header.corr, 7);
13810        let body: subc_protocol::ErrorBody = serde_json::from_slice(&terminal.body).unwrap();
13811        assert_eq!(body.code, SPAWN_SUBSCRIBER_LAGGED_CODE);
13812        let detail = body.detail.expect("lagged error carries detail");
13813        assert_eq!(
13814            detail["first_undelivered_cursor"]["seq"],
13815            data.last().unwrap() + 1,
13816            "the named cursor is the first event the subscriber did not receive"
13817        );
13818        assert_eq!(
13819            detail["first_undelivered_cursor"]["daemon_incarnation"],
13820            "lag-incarnation"
13821        );
13822    }
13823}
13824
13825#[cfg(test)]
13826mod terminal_history_read_concurrency_tests {
13827    use super::*;
13828    use crate::terminal_journal::read_pause;
13829    use cortexkit_test_support::ScratchDir;
13830    use std::sync::mpsc as std_mpsc;
13831
13832    fn journaled_ring(
13833        journal: &Arc<crate::terminal_journal::TerminalJournal>,
13834    ) -> Arc<Mutex<TerminalRing>> {
13835        Arc::new(Mutex::new(
13836            TerminalRing::new(TerminalRingConfig::default(), 1)
13837                .with_journal(Some(Arc::clone(journal))),
13838        ))
13839    }
13840
13841    fn crash(at_ms: u64) -> ExitReport {
13842        ExitReport {
13843            kind: ExitKind::Crash,
13844            code: Some(1),
13845            signal: None,
13846            at_ms,
13847        }
13848    }
13849
13850    /// Record an exit on another thread and report whether it finished within
13851    /// `bound`. The recorder thread is left running if it did not.
13852    fn record_within(
13853        module_id: &'static str,
13854        ring: &Arc<Mutex<TerminalRing>>,
13855        at_ms: u64,
13856        bound: Duration,
13857    ) -> bool {
13858        let ring = Arc::clone(ring);
13859        let (done, done_rx) = std_mpsc::channel();
13860        std::thread::spawn(move || {
13861            record_terminal(
13862                module_id,
13863                &ring,
13864                &SpawnEventFeed::default(),
13865                &crash(at_ms),
13866                TerminalDisposition::Restarting,
13867            );
13868            let _ = done.send(());
13869        });
13870        done_rx.recv_timeout(bound).is_ok()
13871    }
13872
13873    /// A history read in progress must not hold the journal writer (which every
13874    /// module's exit recording needs) or the module's own ring. Exits recorded
13875    /// while the read is paused complete promptly; the paused read answers as of
13876    /// the moment it started, and the next read has each exit exactly once.
13877    #[test]
13878    fn exits_recorded_during_a_paused_history_read_are_not_blocked_or_half_merged() {
13879        let dir = ScratchDir::new("terminal-history-concurrent-read");
13880        let path = dir.join("terminals.jsonl");
13881        let journal = Arc::new(crate::terminal_journal::TerminalJournal::open(
13882            path.clone(),
13883            "daemon".into(),
13884        ));
13885        let reader_ring = journaled_ring(&journal);
13886        let other_ring = journaled_ring(&journal);
13887        assert!(record_within(
13888            "reader-module",
13889            &reader_ring,
13890            10,
13891            Duration::from_secs(5)
13892        ));
13893
13894        let (started, release) = read_pause::install(&path);
13895        let reading = {
13896            let ring = Arc::clone(&reader_ring);
13897            std::thread::spawn(move || durable_terminal_history_of(&ring, "reader-module"))
13898        };
13899        started
13900            .recv_timeout(Duration::from_secs(5))
13901            .expect("the history read reached its pause");
13902
13903        let bound = Duration::from_secs(1);
13904        assert!(
13905            record_within("other-module", &other_ring, 20, bound),
13906            "another module's exit waited on a history read (journal writer held)"
13907        );
13908        assert!(
13909            record_within("reader-module", &reader_ring, 30, bound),
13910            "the read module's own exit waited on its history read (ring held)"
13911        );
13912
13913        drop(release);
13914        let paused = reading.join().unwrap();
13915        assert_eq!(
13916            paused.entries.iter().map(|e| e.at_ms).collect::<Vec<_>>(),
13917            vec![10],
13918            "an exit recorded after the read began lands in neither half of it"
13919        );
13920        assert_eq!(paused.journal_skipped_lines, 0);
13921        assert_eq!(paused.journal_read_errors, 0);
13922
13923        let after = durable_terminal_history_of(&reader_ring, "reader-module");
13924        assert_eq!(
13925            after.entries.iter().map(|e| e.at_ms).collect::<Vec<_>>(),
13926            vec![10, 30],
13927            "the next read merges ring and journal with no duplicate"
13928        );
13929        assert_eq!(after.journal_skipped_lines, 0);
13930    }
13931}
13932
13933/// What a restart does with the exited process's output readers. These drive
13934/// the same settle functions the supervisor calls, with a reader the test
13935/// holds, so a reader that has not been scheduled by the bound is a controlled
13936/// input rather than something only a loaded machine produces.
13937#[cfg(test)]
13938mod stderr_settle_tests {
13939    use std::{
13940        future::Future,
13941        io,
13942        pin::Pin,
13943        sync::{Arc, Mutex},
13944        task::{Context, Poll},
13945        time::Duration,
13946    };
13947
13948    use tokio::{
13949        io::{AsyncRead, ReadBuf},
13950        sync::oneshot,
13951        time::Instant,
13952    };
13953
13954    use super::{settle_stderr_pump, settle_stdout_pump, StderrPump};
13955    use crate::stderr_tail::{
13956        pump_stderr_to, pump_stdout_to, split_capture_stamp, untimed, CaptureState,
13957        ChildOutputSink, OutputSink, StderrRing, StderrTailConfig, TailEntry,
13958    };
13959
13960    const BOUND: Duration = Duration::from_millis(250);
13961
13962    /// Yields `before`, then stays pending until the gate is released, then
13963    /// yields `after` and reaches EOF. The bytes after the gate were written
13964    /// by a process that has already exited; only the reader is behind.
13965    struct HeldReader {
13966        before: Option<Vec<u8>>,
13967        gate: Option<oneshot::Receiver<()>>,
13968        after: io::Cursor<Vec<u8>>,
13969    }
13970
13971    impl AsyncRead for HeldReader {
13972        fn poll_read(
13973            mut self: Pin<&mut Self>,
13974            cx: &mut Context<'_>,
13975            buf: &mut ReadBuf<'_>,
13976        ) -> Poll<io::Result<()>> {
13977            if let Some(bytes) = self.before.take() {
13978                buf.put_slice(&bytes);
13979                return Poll::Ready(Ok(()));
13980            }
13981            if let Some(gate) = self.gate.as_mut() {
13982                match Pin::new(gate).poll(cx) {
13983                    Poll::Pending => return Poll::Pending,
13984                    Poll::Ready(_) => self.gate = None,
13985                }
13986            }
13987            Pin::new(&mut self.after).poll_read(cx, buf)
13988        }
13989    }
13990
13991    struct DiscardSink;
13992
13993    impl OutputSink for DiscardSink {
13994        fn write_line(&mut self, _line: &[u8]) {}
13995    }
13996
13997    fn line(text: &str) -> TailEntry {
13998        TailEntry::Line {
13999            text: text.to_string(),
14000            truncated: false,
14001            at_ms: None,
14002        }
14003    }
14004
14005    fn lock(ring: &Arc<Mutex<StderrRing>>) -> std::sync::MutexGuard<'_, StderrRing> {
14006        ring.lock().unwrap()
14007    }
14008
14009    /// Start a reader for a new process generation that delivers `before`
14010    /// immediately and `after` only once the returned sender fires (or is
14011    /// dropped).
14012    fn held_pump(
14013        ring: &Arc<Mutex<StderrRing>>,
14014        before: &str,
14015        after: &str,
14016    ) -> (StderrPump, oneshot::Sender<()>) {
14017        let generation = lock(ring).begin_process();
14018        let (release, gate) = oneshot::channel();
14019        let reader = HeldReader {
14020            before: Some(before.as_bytes().to_vec()),
14021            gate: Some(gate),
14022            after: io::Cursor::new(after.as_bytes().to_vec()),
14023        };
14024        let task = tokio::spawn(pump_stderr_to(
14025            reader,
14026            Arc::clone(ring),
14027            generation,
14028            DiscardSink,
14029        ));
14030        (StderrPump { task, generation }, release)
14031    }
14032
14033    async fn wait_until(ring: &Arc<Mutex<StderrRing>>, done: impl Fn(&StderrRing) -> bool) {
14034        for _ in 0..1000 {
14035            if done(&lock(ring)) {
14036                return;
14037            }
14038            tokio::time::sleep(Duration::from_millis(1)).await;
14039        }
14040        panic!(
14041            "ring never reached the expected state: {:?}",
14042            lock(ring).snapshot(None, None)
14043        );
14044    }
14045
14046    #[tokio::test(start_paused = true)]
14047    async fn a_crash_line_the_reader_had_not_reached_by_the_bound_is_kept_before_the_restart() {
14048        let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
14049        let (pump, release) = held_pump(&ring, "booting\n", "config error: missing storage\n");
14050
14051        settle_stderr_pump("crasher", &ring, pump, BOUND).await;
14052        let before_release = lock(&ring).snapshot(None, None);
14053        assert!(
14054            matches!(before_release.capture, CaptureState::Incomplete { .. }),
14055            "a reader that has not reached EOF cannot claim a whole tail: {before_release:?}"
14056        );
14057
14058        // The restart: the next process starts and writes before the old
14059        // reader catches up.
14060        let next = lock(&ring).begin_process();
14061        lock(&ring).push_line_from(next, "next process booting");
14062        release.send(()).unwrap();
14063        wait_until(&ring, |ring| {
14064            ring.snapshot(None, None).capture == CaptureState::Captured
14065        })
14066        .await;
14067
14068        assert_eq!(
14069            untimed(lock(&ring).snapshot(None, None).entries),
14070            vec![
14071                line("booting"),
14072                line("config error: missing storage"),
14073                TailEntry::ProcessStart,
14074                line("next process booting"),
14075            ],
14076            "the crash's last line must survive a slow reader and stay in the crashed process's section"
14077        );
14078    }
14079
14080    #[tokio::test(start_paused = true)]
14081    async fn a_pipe_held_open_by_a_descendant_reads_incomplete_without_delaying_the_restart_past_the_bound(
14082    ) {
14083        let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
14084        // `_held` is never fired: a descendant keeps the pipe open for the
14085        // whole test.
14086        let (pump, _held) = held_pump(&ring, "parent exiting\n", "");
14087
14088        let started = Instant::now();
14089        settle_stderr_pump("orphaning", &ring, pump, BOUND).await;
14090        assert_eq!(
14091            started.elapsed(),
14092            BOUND,
14093            "the restart must wait exactly the bound for a pipe that stays open, no longer"
14094        );
14095
14096        let next = lock(&ring).begin_process();
14097        lock(&ring).push_line_from(next, "next process booting");
14098        tokio::time::sleep(Duration::from_secs(60)).await;
14099
14100        let snapshot = lock(&ring).snapshot(None, None);
14101        match &snapshot.capture {
14102            CaptureState::Incomplete { reason } => assert!(
14103                reason.contains("had not reached EOF") && reason.contains("250ms"),
14104                "the reason must say what is missing and after how long: {reason}"
14105            ),
14106            other => panic!("expected Incomplete while the pipe is held open, got {other:?}"),
14107        }
14108        assert_eq!(
14109            untimed(snapshot.entries),
14110            vec![
14111                line("parent exiting"),
14112                TailEntry::ProcessStart,
14113                line("next process booting"),
14114            ]
14115        );
14116    }
14117
14118    #[tokio::test(start_paused = true)]
14119    async fn a_reader_that_reaches_eof_within_the_bound_leaves_the_tail_captured() {
14120        let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
14121        let (pump, release) = held_pump(&ring, "one\n", "two\n");
14122        release.send(()).unwrap();
14123
14124        settle_stderr_pump("clean", &ring, pump, BOUND).await;
14125
14126        let snapshot = lock(&ring).snapshot(None, None);
14127        assert_eq!(snapshot.capture, CaptureState::Captured);
14128        assert_eq!(untimed(snapshot.entries), vec![line("one"), line("two")]);
14129    }
14130
14131    #[tokio::test(start_paused = true)]
14132    async fn stdout_buffered_past_the_exit_drain_bound_reaches_the_capture_file() {
14133        const BURST: usize = 150;
14134        let capture = cortexkit_test_support::ScratchDir::new("stdout-exit-drain");
14135        let path = capture.join("two-pipe.stderr.log");
14136        let sink = ChildOutputSink::open(&path, cortexkit_log::Retention::default()).unwrap();
14137        let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
14138        let generation = lock(&ring).begin_process();
14139        let stdout = (0..BURST)
14140            .map(|i| format!("out-{i:04}-{}\n", "o".repeat(64)))
14141            .collect::<String>();
14142        let stderr = (0..BURST)
14143            .map(|i| format!("err-{i:04}-{}\n", "e".repeat(64)))
14144            .collect::<String>();
14145        // Model a child that has written both bursts and exited. Hold the stdout
14146        // reader after one line and a partial prefix, as if pipe readiness or
14147        // capture-file writes had delayed it. The final line also needs EOF to
14148        // flush: neither complete nor partial buffered output may be discarded.
14149        let first_chunk = stdout.find('\n').unwrap() + 1 + "out-0001-".len();
14150        let (release, gate) = oneshot::channel();
14151        let reader = HeldReader {
14152            before: Some(stdout.as_bytes()[..first_chunk].to_vec()),
14153            gate: Some(gate),
14154            after: io::Cursor::new(stdout.as_bytes()[first_chunk..stdout.len() - 1].to_vec()),
14155        };
14156        let (finished, completion) = oneshot::channel();
14157        let stdout_sink = sink.clone();
14158        let pump = tokio::spawn(async move {
14159            pump_stdout_to(reader, stdout_sink).await;
14160            finished.send(()).unwrap();
14161        });
14162        pump_stderr_to(io::Cursor::new(stderr.into_bytes()), ring, generation, sink).await;
14163
14164        let started = Instant::now();
14165        settle_stdout_pump("two-pipe", pump, BOUND).await;
14166        assert_eq!(
14167            started.elapsed(),
14168            BOUND,
14169            "a late reader must not hold up restart"
14170        );
14171        assert_eq!(
14172            std::fs::read_to_string(&path).unwrap().lines().count(),
14173            BURST + 1
14174        );
14175
14176        release
14177            .send(())
14178            .expect("stdout reader must survive the exit drain bound");
14179        tokio::time::timeout(Duration::from_secs(1), completion)
14180            .await
14181            .expect("released stdout reader must reach EOF")
14182            .expect("stdout capture must finish at EOF, not be cancelled");
14183        let contents = std::fs::read_to_string(&path).unwrap();
14184        let mut lines = contents
14185            .lines()
14186            .map(|line| split_capture_stamp(line).expect("capture stamp").1)
14187            .collect::<Vec<_>>();
14188        let mut expected = (0..BURST)
14189            .flat_map(|i| {
14190                [
14191                    format!("out-{i:04}-{}", "o".repeat(64)),
14192                    format!("err-{i:04}-{}", "e".repeat(64)),
14193                ]
14194            })
14195            .collect::<Vec<_>>();
14196        lines.sort_unstable();
14197        expected.sort_unstable();
14198        assert_eq!(
14199            lines, expected,
14200            "both pipes must retain every complete line exactly once"
14201        );
14202    }
14203}
14204
14205/// Containment of a module's process tree (issue #109).
14206///
14207/// The behaviour these defend against is a module helper surviving its module:
14208/// on a real machine the Synapse embedding module's CUDA worker holds ~2.2 GB of
14209/// VRAM, so a leaked grandchild is a leaked GPU allocation, and a day of restarts
14210/// compounds it.
14211///
14212/// They run against the SUPERVISOR rather than the job-object crate because the
14213/// claim is about teardown: a crate-level test proves a job can reap a tree, not
14214/// that the daemon's drain path reaches it.
14215///
14216/// Windows-only, like the mechanism. On Unix this arm compiles out; the cgroup
14217/// lane there is a separate containment path with its own tests.
14218#[cfg(all(test, windows))]
14219mod job_containment_tests {
14220    use super::*;
14221    use cortexkit_test_support::ScratchDir;
14222    use std::{
14223        path::{Path, PathBuf},
14224        sync::{Arc, Mutex},
14225        time::{Duration, Instant},
14226    };
14227
14228    /// The stub, expected beside this test executable.
14229    ///
14230    /// The existence check is here for the reason its twin at `fake_aft_stub_path`
14231    /// documents: `--lib` does not build `[[bin]]` targets, and a bare spawn
14232    /// failure then reads as a broken test rather than an unbuilt dependency.
14233    fn stub_path() -> PathBuf {
14234        let mut path = std::env::current_exe().expect("current_exe available in tests");
14235        path.pop();
14236        path.pop();
14237        path.push("fake-aft-stub.exe");
14238        assert!(
14239            path.exists(),
14240            "fake-aft-stub not built at {}: run `cargo test -p subc-core` (which builds \
14241             [[bin]] targets) rather than `cargo test -p subc-core --lib` (which does not)",
14242            path.display()
14243        );
14244        path
14245    }
14246
14247    /// Poll for the grandchild pid the stub records, and parse it.
14248    fn read_grandchild_pid(path: &Path) -> u32 {
14249        let deadline = Instant::now() + Duration::from_secs(10);
14250        loop {
14251            if let Ok(contents) = std::fs::read_to_string(path) {
14252                if let Ok(pid) = contents.trim().parse() {
14253                    return pid;
14254                }
14255            }
14256            assert!(
14257                Instant::now() < deadline,
14258                "the stub never recorded a grandchild pid at {}",
14259                path.display()
14260            );
14261            std::thread::sleep(Duration::from_millis(10));
14262        }
14263    }
14264
14265    /// Everything one fixture run needs, so the two tests below differ in exactly
14266    /// one place: whether the child is contained.
14267    struct Fixture {
14268        _dir: ScratchDir,
14269        module_id: String,
14270        grandchild: u32,
14271        child: Option<SupervisedChild>,
14272        registry: Arc<Registry>,
14273        snapshot: Arc<Mutex<SupervisorSnapshot>>,
14274        terminal_ring: Arc<Mutex<TerminalRing>>,
14275        spawn_events: SpawnEventFeed,
14276    }
14277
14278    fn fixture(label: &str, module_id: &str) -> Fixture {
14279        let dir = ScratchDir::new(label);
14280        let pid_file = dir.join("grandchild.pid");
14281        let supervisor = Supervisor::new_for_test(
14282            Arc::new(Registry::default()),
14283            RestartPolicy::new(3, Duration::ZERO),
14284        );
14285        let runtime = supervisor.runtime_config();
14286        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
14287        let spec = ModuleSpec {
14288            module_id: module_id.to_string(),
14289            program: stub_path(),
14290            // Zero args deliberately: a `--subc` argument would make the stub dial
14291            // a daemon that is not there, and the failure would land in the same
14292            // stderr ring this fixture exists to keep quiet.
14293            args: Vec::new(),
14294            env: vec![
14295                ("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string()),
14296                (
14297                    "FAKE_AFT_GRANDCHILD_PID_FILE".to_string(),
14298                    pid_file.display().to_string(),
14299                ),
14300            ],
14301            reserved: false,
14302            reserved_prefixes: Vec::new(),
14303            protocol: ModuleProtocol::Subc,
14304            overlap: Default::default(),
14305        };
14306        let child = spawn_and_mark_running(&spec, &runtime, &snapshot)
14307            .expect("spawn the supervised fixture");
14308        let grandchild = read_grandchild_pid(&pid_file);
14309        Fixture {
14310            _dir: dir,
14311            module_id: module_id.to_string(),
14312            grandchild,
14313            child: Some(child),
14314            registry: Arc::new(Registry::default()),
14315            snapshot,
14316            terminal_ring: Arc::clone(&runtime.terminal_ring),
14317            spawn_events: SpawnEventFeed::default(),
14318        }
14319    }
14320
14321    impl Fixture {
14322        /// Drain through the supervisor's own teardown path.
14323        async fn drain(&mut self) {
14324            let child = self
14325                .child
14326                .take()
14327                .expect("the fixture child is still present");
14328            drain_child_to_state(
14329                &self.module_id,
14330                ModuleProtocol::Subc,
14331                // No forwarding table in this fixture, so nothing reaches the
14332                // child over a connection.
14333                StopNotice::NotSent,
14334                &self.registry,
14335                None,
14336                &self.snapshot,
14337                &self.terminal_ring,
14338                &self.spawn_events,
14339                child,
14340                Duration::from_millis(500),
14341                ModuleState::Stopped,
14342                Some(false),
14343            )
14344            .await
14345            .expect("drain the supervised fixture");
14346        }
14347    }
14348
14349    /// Teardown reaps the grandchild, not merely the direct child.
14350    ///
14351    /// This is the assertion the change exists for. Before containment the
14352    /// grandchild survived: it is a separate process, and `start_kill` is
14353    /// `TerminateProcess` scoped to one pid.
14354    #[tokio::test]
14355    async fn teardown_reaps_the_grandchild() {
14356        let mut fixture = fixture("teardown-grandchild", "tree-teardown");
14357        let grandchild = fixture.grandchild;
14358
14359        assert!(
14360            subc_jobobject::process_exists(grandchild),
14361            "grandchild {grandchild} must be alive before teardown, or this proves nothing"
14362        );
14363
14364        fixture.drain().await;
14365
14366        assert!(
14367            subc_jobobject::wait_for_process_exit(grandchild, Duration::from_secs(10)),
14368            "grandchild {grandchild} outlived module teardown: the tree was not contained"
14369        );
14370    }
14371
14372    /// The mutation control: with containment withheld, the grandchild survives
14373    /// the same kill.
14374    ///
14375    /// This is the defect reproduction from #109 — a direct-child kill reaches
14376    /// one pid, and the grandchild is a different process. It spawns OUTSIDE the
14377    /// supervisor because `spawn_and_mark_running` now always contains on
14378    /// Windows, which is the point: there is no longer a path that spawns
14379    /// uncontained, so the control has to construct one.
14380    ///
14381    /// Its job is to keep `teardown_reaps_the_grandchild` honest. If the
14382    /// grandchild ever dies here, that test is passing for a reason unrelated to
14383    /// the job object and the containment claim is unproven.
14384    #[test]
14385    fn an_uncontained_grandchild_survives_a_direct_child_kill() {
14386        let dir = ScratchDir::new("teardown-uncontained");
14387        let pid_file = dir.join("grandchild.pid");
14388        let mut child = std::process::Command::new(stub_path())
14389            .env("FAKE_AFT_NEVER_CONNECT", "1")
14390            .env(
14391                "FAKE_AFT_GRANDCHILD_PID_FILE",
14392                pid_file.display().to_string(),
14393            )
14394            .stdin(std::process::Stdio::null())
14395            .stdout(std::process::Stdio::null())
14396            .stderr(std::process::Stdio::null())
14397            .spawn()
14398            .expect("spawn the uncontained fixture");
14399        let grandchild = read_grandchild_pid(&pid_file);
14400
14401        // Exactly what the pre-fix teardown did: kill the direct child.
14402        child.kill().expect("kill the direct child");
14403        let _ = child.wait();
14404
14405        assert!(
14406            subc_jobobject::process_exists(grandchild),
14407            "grandchild {grandchild} died with the direct child, so this control no longer \
14408             distinguishes contained from uncontained teardown and the regression test is \
14409             passing vacuously"
14410        );
14411
14412        // The orphan this control demonstrates is the leak the fix prevents, so
14413        // the control must not leave one behind.
14414        kill_tree(grandchild);
14415    }
14416
14417    /// Crash durability: closing the containment handle reaps the tree with no
14418    /// teardown code running at all.
14419    ///
14420    /// This is the case `taskkill /T` cannot cover — a daemon that dies cannot
14421    /// call anything — and it is why containment is a kernel property of the
14422    /// handle rather than a step in the drain. Discovered by getting the
14423    /// mutation control wrong: clearing `job` to "disable" containment instead
14424    /// killed the tree, which is the guarantee, not a mistake.
14425    #[tokio::test]
14426    async fn dropping_containment_reaps_the_grandchild() {
14427        let mut fixture = fixture("drop-containment", "tree-drop");
14428        let grandchild = fixture.grandchild;
14429
14430        assert!(subc_jobobject::process_exists(grandchild));
14431
14432        // No `drain` call, no kill: dropping the handle is the entire mechanism.
14433        fixture.child.as_mut().expect("child present").job = None;
14434
14435        assert!(
14436            subc_jobobject::wait_for_process_exit(grandchild, Duration::from_secs(10)),
14437            "grandchild {grandchild} survived the containment handle closing, so a daemon \
14438             crash would leave the tree behind"
14439        );
14440    }
14441
14442    /// Kill a pid and its tree, then confirm it is gone.
14443    fn kill_tree(pid: u32) {
14444        let _ = std::process::Command::new("taskkill.exe")
14445            .args(["/PID", &pid.to_string(), "/T", "/F"])
14446            .stdin(std::process::Stdio::null())
14447            .stdout(std::process::Stdio::null())
14448            .stderr(std::process::Stdio::null())
14449            .status();
14450        assert!(
14451            subc_jobobject::wait_for_process_exit(pid, Duration::from_secs(10)),
14452            "could not clean up grandchild {pid}"
14453        );
14454    }
14455}
14456
14457#[cfg(test)]
14458mod privacy_trampoline_configuration_tests {
14459    #[tokio::test]
14460    #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
14461    async fn macos_spawn_without_a_configured_trampoline_refuses_by_name() {
14462        #[cfg(target_os = "macos")]
14463        {
14464            let supervisor = super::Supervisor::new(
14465                std::sync::Arc::new(crate::Registry::default()),
14466                super::RestartPolicy::default(),
14467            );
14468            let error = supervisor.spawn(spec()).unwrap_err();
14469            assert!(
14470                error
14471                    .to_string()
14472                    .contains("no privacy trampoline configured"),
14473                "{error}"
14474            );
14475        }
14476    }
14477
14478    #[tokio::test]
14479    #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
14480    async fn macos_wrong_trampoline_is_rejected_by_the_startup_probe() {
14481        #[cfg(target_os = "macos")]
14482        {
14483            let supervisor = super::Supervisor::new(
14484                std::sync::Arc::new(crate::Registry::default()),
14485                super::RestartPolicy::default(),
14486            )
14487            .with_privacy_trampoline(std::env::current_exe().unwrap());
14488            let error = supervisor.spawn(spec()).unwrap_err();
14489            assert!(
14490                error
14491                    .to_string()
14492                    .contains("binary does not implement the privacy trampoline protocol"),
14493                "{error}"
14494            );
14495        }
14496    }
14497
14498    #[cfg(target_os = "macos")]
14499    fn spec() -> super::ModuleSpec {
14500        super::ModuleSpec {
14501            module_id: "privacy-configuration".into(),
14502            program: "/bin/sleep".into(),
14503            args: vec!["30".into()],
14504            env: vec![],
14505            reserved: false,
14506            reserved_prefixes: vec![],
14507            protocol: subc_control::ModuleProtocol::None,
14508            overlap: super::ModuleOverlap::Exclusive,
14509        }
14510    }
14511}
14512
14513#[cfg(test)]
14514mod privacy_exec_boundary_tests {
14515    #[cfg(target_os = "macos")]
14516    use super::*;
14517    #[cfg(target_os = "macos")]
14518    use std::{
14519        io::{Read, Write},
14520        net::{TcpListener, TcpStream},
14521    };
14522
14523    /// Unit-test-only pause at the actual early image read, not at a later
14524    /// status read. Production supervisors never inspect this environment key.
14525    #[cfg(target_os = "macos")]
14526    pub(super) fn before_image_sample(spec: &ModuleSpec, pid: u32) {
14527        if let Some((_, path)) = spec
14528            .env
14529            .iter()
14530            .find(|(key, _)| key == "SUBC_TEST_PRIVACY_SAMPLE_BARRIER")
14531        {
14532            let mut barrier = TcpStream::connect(path).unwrap();
14533            barrier
14534                .set_read_timeout(Some(Duration::from_secs(30)))
14535                .unwrap();
14536            barrier.write_all(&pid.to_ne_bytes()).unwrap();
14537            let mut release = [0];
14538            barrier.read_exact(&mut release).unwrap();
14539            assert_eq!(&release, b"X");
14540        }
14541    }
14542
14543    #[cfg(target_os = "macos")]
14544    fn spec(program: &str, args: &[&str]) -> ModuleSpec {
14545        ModuleSpec {
14546            module_id: "privacy-boundary".into(),
14547            program: program.into(),
14548            args: args.iter().map(|arg| (*arg).into()).collect(),
14549            env: vec![],
14550            reserved: false,
14551            reserved_prefixes: vec![],
14552            protocol: ModuleProtocol::None,
14553            overlap: ModuleOverlap::Exclusive,
14554        }
14555    }
14556
14557    #[cfg(target_os = "macos")]
14558    async fn accept(listener: TcpListener) -> TcpStream {
14559        // Socket readiness, not elapsed time, establishes both pause points.
14560        let listener = tokio::net::TcpListener::from_std({
14561            listener.set_nonblocking(true).unwrap();
14562            listener
14563        })
14564        .unwrap();
14565        let (stream, _) = tokio::time::timeout(Duration::from_secs(30), listener.accept())
14566            .await
14567            .unwrap()
14568            .unwrap();
14569        let stream = stream.into_std().unwrap();
14570        stream.set_nonblocking(false).unwrap();
14571        stream
14572            .set_read_timeout(Some(Duration::from_secs(30)))
14573            .unwrap();
14574        stream
14575    }
14576
14577    #[tokio::test]
14578    #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
14579    async fn macos_roster_withholds_a_nonnull_trampoline_image_until_exec_confirmation() {
14580        #[cfg(target_os = "macos")]
14581        {
14582            let root = cortexkit_test_support::ScratchDir::new("privacy-roster-barrier");
14583            // Loopback sockets also work when the replay adapter's TMPDIR is
14584            // longer than Darwin's Unix-domain socket path limit.
14585            let exec_listener = TcpListener::bind("127.0.0.1:0").unwrap();
14586            let sample_listener = TcpListener::bind("127.0.0.1:0").unwrap();
14587            let record = root.join("live-children.json");
14588            let supervisor =
14589                Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
14590                    .with_live_children_record(&record);
14591            let runtime = supervisor.runtime_config();
14592            let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
14593            let mut spec = spec("/bin/sleep", &["30"]);
14594            spec.env = vec![
14595                (
14596                    "SUBC_TEST_PRIVACY_EXEC_BARRIER".into(),
14597                    exec_listener.local_addr().unwrap().to_string(),
14598                ),
14599                (
14600                    "SUBC_TEST_PRIVACY_SAMPLE_BARRIER".into(),
14601                    sample_listener.local_addr().unwrap().to_string(),
14602                ),
14603            ];
14604            let spawn = tokio::task::spawn_blocking(move || {
14605                spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap()
14606            });
14607            let mut sample = accept(sample_listener).await;
14608            let mut pid = [0; 4];
14609            sample.read_exact(&mut pid).unwrap();
14610            let pid = u32::from_ne_bytes(pid);
14611            let mut exec = accept(exec_listener).await;
14612            let mut ready = [0];
14613            exec.read_exact(&mut ready).unwrap();
14614            assert_eq!(&ready, b"R");
14615            // The early read is guaranteed to see a real, non-null trampoline
14616            // image: the fixture has reached its barrier and cannot exec yet.
14617            let trampoline = subc_os::file_identity(&test_privacy_trampoline()).unwrap();
14618            assert_eq!(
14619                observe_spawned_image(pid).unwrap().executable,
14620                Some(trampoline)
14621            );
14622            sample.write_all(b"X").unwrap();
14623            let mut child = spawn.await.unwrap();
14624            let early = crate::live_children::read_record(&record).unwrap();
14625            assert_eq!(early.len(), 1);
14626            assert_eq!(early[0].pid, pid);
14627            assert_eq!(
14628                early[0].executable, None,
14629                "unconfirmed trampoline image entered the roster"
14630            );
14631            assert!(child.report_ready.get().is_none());
14632            // The barrier's duration is unrelated to the production five-second
14633            // exec budget. Start the test's confirmation budget upon release.
14634            child.privacy_exec.as_mut().unwrap().deadline =
14635                tokio::time::Instant::now() + Duration::from_secs(30);
14636            exec.write_all(b"X").unwrap();
14637            child.confirm_privacy_exec().await;
14638            assert_eq!(child.spawn_failure, None);
14639            assert!(child.report_ready.get().is_some());
14640            let confirmed = crate::live_children::read_record(&record).unwrap();
14641            let module = subc_os::file_identity(std::path::Path::new("/bin/sleep")).unwrap();
14642            assert_ne!(module, trampoline);
14643            assert_eq!(confirmed[0].executable, Some(module.into()));
14644            child.start_kill().unwrap();
14645            child.wait().await.unwrap();
14646            child.release_roster();
14647        }
14648    }
14649
14650    #[tokio::test]
14651    #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
14652    async fn macos_already_exited_121_in_try_wait_is_a_module_exit_not_a_trampoline_refusal() {
14653        #[cfg(target_os = "macos")]
14654        {
14655            let registry = Arc::new(Registry::default());
14656            let policy = RestartPolicy::new(0, Duration::ZERO);
14657            let supervisor = Supervisor::new_for_test(Arc::clone(&registry), policy);
14658            let runtime = supervisor.runtime_config();
14659            let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
14660            let spec = spec("/bin/sh", &["-c", "exit 121"]);
14661            // Drive spawn and confirmation separately instead of starting the
14662            // monitor. WNOWAIT observes a real exit without consuming its status,
14663            // so confirmation's first try_wait must take the already-exited arm.
14664            let mut child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
14665            let pid = child.pid;
14666            tokio::task::spawn_blocking(move || {
14667                subc_os::fork_exec_test::wait_for_child_exit_without_reaping(pid)
14668            })
14669            .await
14670            .unwrap()
14671            .unwrap();
14672            child.privacy_exec.as_mut().unwrap().deadline =
14673                tokio::time::Instant::now() + Duration::from_secs(30);
14674            let status = child.wait().await.unwrap();
14675            assert_eq!(status.code(), Some(121));
14676            assert!(child.privacy_exec.is_none());
14677            assert!(
14678                child.report_ready.get().is_none(),
14679                "an exited module must not publish a live pid"
14680            );
14681            let report = classify_reaped_child_exit(&snapshot, &child, &status);
14682            on_child_exit(
14683                &spec,
14684                policy,
14685                &registry,
14686                &snapshot,
14687                &runtime.terminal_ring,
14688                &runtime.spawn_events,
14689                &runtime.child_roster,
14690                report,
14691            )
14692            .await;
14693            let state = lock_snapshot(&snapshot).unwrap();
14694            assert_eq!(state.state, ModuleState::Failed);
14695            assert_eq!(state.last_exit.as_ref().unwrap().code, Some(121));
14696            assert_eq!(state.reported_pid(), None);
14697            drop(state);
14698            let history = runtime.terminal_ring.lock().unwrap().snapshot();
14699            assert_eq!(history.entries.len(), 1);
14700            let terminal = &history.entries[0];
14701            assert_eq!(terminal.exit_code, Some(121));
14702            assert_eq!(terminal.exit_kind, subc_control::TerminalExitKind::Crash);
14703            assert_eq!(terminal.disposition, TerminalDisposition::Failed);
14704            assert_eq!(
14705                terminal.disposition_detail.as_deref(),
14706                Some(policy.budget_exhausted_detail().as_str()),
14707                "module exit 121 was classified as a trampoline refusal: {terminal:?}"
14708            );
14709            assert_eq!(child.spawn_failure, None);
14710            child.release_roster();
14711        }
14712    }
14713}
14714
14715/// The daemon's real spawn path hands a subc-wire child its launch nonce on
14716/// descriptor 3, without an environment copy. The shell records the nonce
14717/// and its environment after exec so these tests observe the real handover.
14718#[cfg(all(test, unix))]
14719mod launch_nonce_descriptor_tests {
14720    use super::{ChildRoster, ModuleProtocol, ModuleSpec, SupervisorHandle};
14721    use crate::stderr_tail::{StderrRing, StderrTailConfig};
14722    use cortexkit_test_support::ScratchDir;
14723    use std::{
14724        path::PathBuf,
14725        sync::{Arc, Mutex},
14726        time::{Duration, Instant},
14727    };
14728
14729    async fn probe(role: super::SpawnRole) {
14730        let scratch = ScratchDir::new("launch-nonce-descriptor");
14731        let fd_copy = scratch.join("from-descriptor");
14732        let env_copy = scratch.join("environment");
14733        let script = format!(
14734            "env > '{env}.tmp' && mv '{env}.tmp' '{env}'; cat <&3 > '{fd}.tmp' && mv '{fd}.tmp' '{fd}'; sleep 30",
14735            fd = fd_copy.display(), env = env_copy.display(),
14736        );
14737        let xdg = |name: &str| (name.to_string(), scratch.join(name).display().to_string());
14738        let spec = ModuleSpec {
14739            module_id: "nonce-descriptor-probe".to_string(),
14740            program: PathBuf::from("/bin/sh"),
14741            args: vec!["-c".to_string(), script],
14742            env: vec![
14743                xdg("XDG_DATA_HOME"),
14744                xdg("XDG_RUNTIME_DIR"),
14745                xdg("XDG_CONFIG_HOME"),
14746                ("SUBC_LAUNCH_NONCE".to_string(), "must-not-leak".to_string()),
14747            ],
14748            reserved: true,
14749            reserved_prefixes: Vec::new(),
14750            protocol: ModuleProtocol::Subc,
14751            overlap: Default::default(),
14752        };
14753        let handle = SupervisorHandle::new();
14754        let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
14755        let roster = ChildRoster::default();
14756        #[cfg(target_os = "macos")]
14757        {
14758            let path = super::test_privacy_trampoline();
14759            roster.set_privacy_trampoline(super::probe_privacy_trampoline(&path).map(|()| path));
14760        }
14761        let child = super::spawn_child_in_slot(
14762            &spec,
14763            None,
14764            Some(&handle),
14765            &ring,
14766            None,
14767            &roster,
14768            #[cfg(target_os = "linux")]
14769            None,
14770            role,
14771            matches!(role, super::SpawnRole::SwapCandidate),
14772        )
14773        .expect("spawn probe");
14774        let deadline = Instant::now() + Duration::from_secs(10);
14775        while !(fd_copy.exists() && env_copy.exists()) {
14776            assert!(Instant::now() < deadline, "probe never wrote its copies");
14777            tokio::time::sleep(Duration::from_millis(20)).await;
14778        }
14779        let nonce = std::fs::read_to_string(fd_copy).unwrap();
14780        assert!(!nonce.is_empty());
14781        let environment = std::fs::read_to_string(env_copy).unwrap();
14782        assert!(environment
14783            .lines()
14784            .any(|line| line.starts_with("SUBC_LAUNCH_NONCE_FD=3:")));
14785        let copy = environment
14786            .lines()
14787            .find_map(|line| line.strip_prefix("SUBC_LAUNCH_NONCE="));
14788        assert_eq!(
14789            copy, None,
14790            "Unix children must never receive the environment nonce"
14791        );
14792        if matches!(role, super::SpawnRole::Plain) {
14793            assert_eq!(
14794                handle.spawn_nonce(&spec.module_id).as_deref(),
14795                Some(nonce.as_str())
14796            );
14797        }
14798        drop(child);
14799    }
14800
14801    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
14802    async fn a_spawned_module_receives_its_nonce_only_on_descriptor_3() {
14803        probe(super::SpawnRole::Plain).await;
14804    }
14805
14806    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
14807    async fn swap_candidate_receives_its_nonce_only_on_descriptor_3() {
14808        probe(super::SpawnRole::SwapCandidate).await;
14809    }
14810}
14811
14812#[cfg(all(test, target_os = "linux"))]
14813mod cgroup_containment_tests {
14814    use super::*;
14815    use cortexkit_test_support::ScratchDir;
14816
14817    fn running(pid: u32) -> bool {
14818        // An orphan can remain a zombie until the container init reaps it.
14819        std::fs::read_to_string(format!("/proc/{pid}/stat"))
14820            .ok()
14821            .and_then(|stat| {
14822                stat.rsplit_once(") ")
14823                    .map(|(_, rest)| rest.starts_with('Z'))
14824            })
14825            .is_some_and(|zombie| !zombie)
14826    }
14827
14828    #[tokio::test]
14829    async fn linux_teardown_reaps_the_grandchild() {
14830        teardown_tree("linux_teardown_reaps_the_grandchild", false).await;
14831    }
14832
14833    #[tokio::test]
14834    async fn linux_shutdown_straggler_reaps_the_grandchild() {
14835        teardown_tree("linux_shutdown_straggler_reaps_the_grandchild", true).await;
14836    }
14837
14838    async fn teardown_tree(test_name: &str, shutdown: bool) {
14839        let dir = ScratchDir::new(test_name);
14840        let root = PathBuf::from(format!(
14841            "/sys/fs/cgroup/subc-tree-test-{test_name}-{}-{}",
14842            std::process::id(),
14843            unix_ms_now()
14844        ));
14845        if let Err(error) = std::fs::create_dir(&root) {
14846            assert!(
14847                std::env::var_os("SUBC_REQUIRE_CGROUP_TEST").is_none(),
14848                "required cgroup test cannot execute: {error}"
14849            );
14850            eprintln!(
14851                "SKIP {test_name}: no writable delegated cgroup at {}: {error}",
14852                root.display()
14853            );
14854            return;
14855        }
14856        let placement = subc_cgroup::prepare_at(&root)
14857            .expect("prepare isolated kernel cgroup")
14858            .expect("isolated cgroup is delegated");
14859        let module_id = "tree-teardown";
14860        let module = placement
14861            .module_path(module_id)
14862            .expect("create isolated module cgroup");
14863        if !module.join("cgroup.kill").exists() {
14864            std::fs::remove_dir(&module).unwrap();
14865            std::fs::remove_dir(root.join("subc-modules")).unwrap();
14866            std::fs::remove_dir(&root).unwrap();
14867            assert!(
14868                std::env::var_os("SUBC_REQUIRE_CGROUP_TEST").is_none(),
14869                "required cgroup.kill interface unavailable"
14870            );
14871            eprintln!("SKIP {test_name}: cgroup.kill unavailable (kernel < 5.14)");
14872            return;
14873        }
14874        let supervisor = Supervisor::new_for_test(
14875            Arc::new(Registry::default()),
14876            RestartPolicy::new(3, Duration::ZERO),
14877        )
14878        .with_cgroup_placement(Some(placement));
14879        let mut runtime = supervisor.runtime_config();
14880        runtime.child_roster = runtime
14881            .child_roster
14882            .for_module(Arc::new(Mutex::new(Duration::from_millis(100))));
14883        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
14884        let pid_file = dir.join("grandchild.pid");
14885        let spec = ModuleSpec {
14886            module_id: module_id.to_string(),
14887            program: PathBuf::from("/bin/sh"),
14888            args: vec![
14889                "-c".into(),
14890                "trap '' TERM; sleep 600 & echo $! > \"$1\"; wait".into(),
14891                "fixture".into(),
14892                pid_file.display().to_string(),
14893            ],
14894            env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
14895                .into_iter()
14896                .map(|key| (key.to_string(), dir.display().to_string()))
14897                .collect(),
14898            reserved: false,
14899            reserved_prefixes: Vec::new(),
14900            protocol: ModuleProtocol::None,
14901            overlap: Default::default(),
14902        };
14903        let child =
14904            spawn_and_mark_running(&spec, &runtime, &snapshot).expect("spawn supervised tree");
14905        let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
14906        let grandchild: u32 = loop {
14907            if let Ok(contents) = std::fs::read_to_string(&pid_file) {
14908                if let Ok(pid) = contents.trim().parse() {
14909                    break pid;
14910                }
14911            }
14912            assert!(
14913                tokio::time::Instant::now() < deadline,
14914                "grandchild pid was not recorded"
14915            );
14916            tokio::time::sleep(Duration::from_millis(10)).await;
14917        };
14918        assert!(
14919            running(grandchild),
14920            "grandchild must be alive before teardown"
14921        );
14922        if shutdown {
14923            let mut child = child;
14924            crate::child_roster::end_children_for_daemon_shutdown(
14925                &runtime.child_roster,
14926                false,
14927                std::future::pending(),
14928            )
14929            .await;
14930            child.wait().await.expect("reap shutdown straggler");
14931        } else {
14932            drain_child_to_state(
14933                module_id,
14934                ModuleProtocol::None,
14935                StopNotice::NotSent,
14936                &Registry::default(),
14937                None,
14938                &snapshot,
14939                &runtime.terminal_ring,
14940                &SpawnEventFeed::default(),
14941                child,
14942                Duration::from_millis(100),
14943                ModuleState::Stopped,
14944                Some(false),
14945            )
14946            .await
14947            .expect("real supervisor teardown");
14948        }
14949        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
14950        while running(grandchild) && tokio::time::Instant::now() < deadline {
14951            tokio::time::sleep(Duration::from_millis(10)).await;
14952        }
14953        let survived = running(grandchild);
14954        // Kill a surviving grandchild so a failed test does not leave it behind.
14955        if survived {
14956            let pid = rustix::process::Pid::from_raw(grandchild as i32).unwrap();
14957            let _ = rustix::process::kill_process(pid, rustix::process::Signal::KILL);
14958            tokio::time::sleep(Duration::from_millis(100)).await;
14959        }
14960        if module.exists() {
14961            std::fs::remove_dir(&module).expect("remove empty module cgroup");
14962        }
14963        std::fs::remove_dir(root.join("subc-modules")).unwrap();
14964        std::fs::remove_dir(&root).unwrap();
14965        assert!(
14966            !survived,
14967            "grandchild {grandchild} outlived module teardown"
14968        );
14969        eprintln!("EXECUTED {test_name}: grandchild {grandchild} killed in isolated cgroup");
14970    }
14971}