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 a replacement has already been scheduled for the current process.
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            _ if snapshot.enabled && snapshot.respawn_pending => true,
3578            _ => daemon_will_restart(&mut snapshot, &self.inner.restart_policy, Instant::now()),
3579        })
3580    }
3581
3582    #[cfg(test)]
3583    pub(crate) fn is_warming(&self) -> Result<bool, SuperviseError> {
3584        self.is_warming_with_snapshot_lock(None)
3585    }
3586
3587    pub(crate) fn is_warming_for_control(
3588        &self,
3589        caller: &'static str,
3590    ) -> Result<bool, SuperviseError> {
3591        self.is_warming_with_snapshot_lock(Some(caller))
3592    }
3593
3594    fn is_warming_with_snapshot_lock(
3595        &self,
3596        caller: Option<&'static str>,
3597    ) -> Result<bool, SuperviseError> {
3598        let snapshot = match caller {
3599            Some(caller) => {
3600                lock_snapshot_for_control(&self.inner.snapshot, &self.inner.module_id, caller)?
3601            }
3602            None => lock_snapshot(&self.inner.snapshot)?,
3603        }
3604        .clone();
3605        Ok(matches!(
3606            snapshot.state,
3607            ModuleState::Starting | ModuleState::Running | ModuleState::Restarting
3608        ))
3609    }
3610
3611    /// Drain the module and stop monitoring it.
3612    pub async fn drain(&self) -> Result<(), SuperviseError> {
3613        self.stop().await
3614    }
3615
3616    pub(crate) async fn retire_requested(
3617        &self,
3618        requester: Option<Principal>,
3619    ) -> Result<(), SuperviseError> {
3620        match self.state()? {
3621            ModuleState::Stopped | ModuleState::Failed => return Ok(()),
3622            ModuleState::Starting
3623            | ModuleState::Running
3624            | ModuleState::Unresponsive
3625            | ModuleState::Restarting
3626            | ModuleState::Draining
3627            | ModuleState::Disabled => {}
3628        }
3629
3630        let (reply_tx, reply_rx) = oneshot::channel();
3631        self.inner
3632            .commands
3633            .send(SupervisorCommand::Retire {
3634                requester,
3635                reply: reply_tx,
3636            })
3637            .await
3638            .map_err(|_| SuperviseError::CommandClosed {
3639                module_id: self.inner.module_id.clone(),
3640            })?;
3641        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3642            module_id: self.inner.module_id.clone(),
3643        })?
3644    }
3645
3646    pub async fn stop(&self) -> Result<(), SuperviseError> {
3647        self.stop_requested(None).await
3648    }
3649
3650    pub(crate) async fn stop_requested(
3651        &self,
3652        requester: Option<Principal>,
3653    ) -> Result<(), SuperviseError> {
3654        match self.state()? {
3655            ModuleState::Stopped | ModuleState::Failed => return Ok(()),
3656            ModuleState::Starting
3657            | ModuleState::Running
3658            | ModuleState::Unresponsive
3659            | ModuleState::Restarting
3660            | ModuleState::Draining
3661            | ModuleState::Disabled => {}
3662        }
3663
3664        let (reply_tx, reply_rx) = oneshot::channel();
3665        self.inner
3666            .commands
3667            .send(SupervisorCommand::Drain {
3668                requester,
3669                reply: reply_tx,
3670            })
3671            .await
3672            .map_err(|_| SuperviseError::CommandClosed {
3673                module_id: self.inner.module_id.clone(),
3674            })?;
3675        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3676            module_id: self.inner.module_id.clone(),
3677        })?
3678    }
3679
3680    pub async fn restart(&self, drain_timeout_ms: Option<u64>) -> Result<(), SuperviseError> {
3681        self.restart_requested(drain_timeout_ms, None).await
3682    }
3683
3684    pub(crate) async fn restart_requested(
3685        &self,
3686        drain_timeout_ms: Option<u64>,
3687        requester: Option<Principal>,
3688    ) -> Result<(), SuperviseError> {
3689        let received_at_generation = lock_snapshot(&self.inner.snapshot)?.spawn_generation;
3690        let (reply_tx, reply_rx) = oneshot::channel();
3691        self.inner
3692            .commands
3693            .send(SupervisorCommand::Restart {
3694                requester,
3695                drain_timeout_ms,
3696                received_at_generation,
3697                queued_at: Instant::now(),
3698                reply: reply_tx,
3699            })
3700            .await
3701            .map_err(|_| SuperviseError::CommandClosed {
3702                module_id: self.inner.module_id.clone(),
3703            })?;
3704        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3705            module_id: self.inner.module_id.clone(),
3706        })?
3707    }
3708
3709    /// Blue/green restart: see [`SupervisorCommand::Swap`] and the
3710    /// `supervisor_swap` module. Returns once the swap has cut over (the old
3711    /// process then drains in the background of the supervise loop) or has
3712    /// failed, leaving the old process serving.
3713    pub async fn swap(&self, ready_timeout: Option<Duration>) -> Result<(), SuperviseError> {
3714        self.swap_requested(ready_timeout, None).await
3715    }
3716
3717    pub(crate) async fn swap_requested(
3718        &self,
3719        ready_timeout: Option<Duration>,
3720        requester: Option<Principal>,
3721    ) -> Result<(), SuperviseError> {
3722        let (reply_tx, reply_rx) = oneshot::channel();
3723        self.inner
3724            .commands
3725            .send(SupervisorCommand::Swap {
3726                requester,
3727                ready_timeout,
3728                reply: reply_tx,
3729            })
3730            .await
3731            .map_err(|_| SuperviseError::CommandClosed {
3732                module_id: self.inner.module_id.clone(),
3733            })?;
3734        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3735            module_id: self.inner.module_id.clone(),
3736        })?
3737    }
3738
3739    pub async fn reload(&self) -> Result<(), SuperviseError> {
3740        self.reload_requested(None).await
3741    }
3742
3743    pub(crate) async fn reload_requested(
3744        &self,
3745        requester: Option<Principal>,
3746    ) -> Result<(), SuperviseError> {
3747        let (reply_tx, reply_rx) = oneshot::channel();
3748        self.inner
3749            .commands
3750            .send(SupervisorCommand::Reload {
3751                requester,
3752                reply: reply_tx,
3753            })
3754            .await
3755            .map_err(|_| SuperviseError::CommandClosed {
3756                module_id: self.inner.module_id.clone(),
3757            })?;
3758        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3759            module_id: self.inner.module_id.clone(),
3760        })?
3761    }
3762
3763    pub async fn set_enabled(&self, enabled: bool) -> Result<bool, SuperviseError> {
3764        self.set_enabled_requested(enabled, None).await
3765    }
3766
3767    pub(crate) async fn set_enabled_requested(
3768        &self,
3769        enabled: bool,
3770        requester: Option<Principal>,
3771    ) -> Result<bool, SuperviseError> {
3772        let (reply_tx, reply_rx) = oneshot::channel();
3773        self.inner
3774            .commands
3775            .send(SupervisorCommand::SetEnabled {
3776                requester,
3777                enabled,
3778                reply: reply_tx,
3779            })
3780            .await
3781            .map_err(|_| SuperviseError::CommandClosed {
3782                module_id: self.inner.module_id.clone(),
3783            })?;
3784        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3785            module_id: self.inner.module_id.clone(),
3786        })?
3787    }
3788
3789    /// The current process's protocol, or the configured protocol when down.
3790    /// A rescan stores the next launch spec without changing how an existing
3791    /// process registers, serves routes, is probed, or exits.
3792    pub(crate) fn declared_protocol(&self) -> Result<ModuleProtocol, SuperviseError> {
3793        let configured = self
3794            .inner
3795            .configuration
3796            .lock()
3797            .map_err(|_| SuperviseError::StatePoisoned {
3798                module_id: Some(self.inner.module_id.clone()),
3799            })?
3800            .spec
3801            .protocol;
3802        let state = lock_snapshot(&self.inner.snapshot)?;
3803        Ok(state.spawned_protocol.unwrap_or(configured))
3804    }
3805
3806    pub(crate) fn configuration(&self) -> Result<(ModuleSpec, HealthConfig), SuperviseError> {
3807        let configuration =
3808            self.inner
3809                .configuration
3810                .lock()
3811                .map_err(|_| SuperviseError::StatePoisoned {
3812                    module_id: Some(self.inner.module_id.clone()),
3813                })?;
3814        Ok((configuration.spec.clone(), configuration.health.clone()))
3815    }
3816
3817    /// Replace this module's launch spec, keeping its health and drain policy,
3818    /// the way a rescan does for a changed config entry. The running process is
3819    /// untouched; the next spawn (a restart, or a swap's candidate) uses it.
3820    #[cfg(any(test, feature = "test-support"))]
3821    pub async fn update_spec_for_test(&self, spec: ModuleSpec) -> Result<(), SuperviseError> {
3822        let (_, health) = self.configuration()?;
3823        let drain_timeout_ms = u64::try_from(
3824            self.inner
3825                .effective_drain_timeout
3826                .lock()
3827                .unwrap_or_else(|poisoned| poisoned.into_inner())
3828                .as_millis(),
3829        )
3830        .ok();
3831        self.update_configuration(spec, health, drain_timeout_ms)
3832            .await
3833    }
3834
3835    pub(crate) async fn update_configuration(
3836        &self,
3837        spec: ModuleSpec,
3838        health: HealthConfig,
3839        drain_timeout_ms: Option<u64>,
3840    ) -> Result<(), SuperviseError> {
3841        if spec.module_id != self.inner.module_id {
3842            return Err(SuperviseError::InvalidSpec {
3843                reason: "a supervised module's module_id cannot be changed".to_string(),
3844            });
3845        }
3846        validate_spec(&spec)?;
3847        let (reply_tx, reply_rx) = oneshot::channel();
3848        self.inner
3849            .commands
3850            .send(SupervisorCommand::UpdateConfiguration {
3851                spec: spec.clone(),
3852                health: health.clone(),
3853                drain_timeout_ms,
3854                reply: reply_tx,
3855            })
3856            .await
3857            .map_err(|_| SuperviseError::CommandClosed {
3858                module_id: self.inner.module_id.clone(),
3859            })?;
3860        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3861            module_id: self.inner.module_id.clone(),
3862        })?;
3863        let mut configuration =
3864            self.inner
3865                .configuration
3866                .lock()
3867                .map_err(|_| SuperviseError::StatePoisoned {
3868                    module_id: Some(self.inner.module_id.clone()),
3869                })?;
3870        configuration.spec = spec;
3871        configuration.health = health;
3872        Ok(())
3873    }
3874}
3875
3876impl Drop for SupervisedModuleInner {
3877    fn drop(&mut self) {
3878        let Ok(mut monitor) = self.monitor.lock() else {
3879            return;
3880        };
3881        if let Some(monitor) = monitor.as_ref().filter(|monitor| !monitor.is_finished()) {
3882            let _ = update_snapshot(&self.snapshot, Some(&self.module_id), |state| {
3883                state.state = ModuleState::Stopped;
3884                clear_current_process_facts(state);
3885            });
3886            monitor.abort();
3887        }
3888        let _ = monitor.take();
3889    }
3890}
3891
3892#[derive(Debug)]
3893enum SupervisorCommand {
3894    Drain {
3895        requester: Option<Principal>,
3896        reply: oneshot::Sender<Result<(), SuperviseError>>,
3897    },
3898    Retire {
3899        requester: Option<Principal>,
3900        reply: oneshot::Sender<Result<(), SuperviseError>>,
3901    },
3902    Restart {
3903        requester: Option<Principal>,
3904        /// Operator override for this one restart's drain budget, in ms. `None`
3905        /// uses the module's configured/default budget; `Some(0)` cuts
3906        /// immediately (wedge bounce: a stuck request never settles, so
3907        /// waiting only delays recovery).
3908        drain_timeout_ms: Option<u64>,
3909        /// The module's `spawn_generation` when the request was received, before
3910        /// it waited in the command queue. A queued restart whose module has
3911        /// since spawned a newer process is already satisfied (see the handler).
3912        received_at_generation: u64,
3913        /// When the request entered the command queue, so the handler can log
3914        /// how long it waited behind the loop's other work.
3915        queued_at: Instant,
3916        reply: oneshot::Sender<Result<(), SuperviseError>>,
3917    },
3918    Reload {
3919        requester: Option<Principal>,
3920        reply: oneshot::Sender<Result<(), SuperviseError>>,
3921    },
3922    SetEnabled {
3923        requester: Option<Principal>,
3924        enabled: bool,
3925        reply: oneshot::Sender<Result<bool, SuperviseError>>,
3926    },
3927    UpdateConfiguration {
3928        spec: ModuleSpec,
3929        health: HealthConfig,
3930        /// Per-module drain override from the new config; `None` re-resolves to
3931        /// the supervisor-wide default.
3932        drain_timeout_ms: Option<u64>,
3933        reply: oneshot::Sender<()>,
3934    },
3935    Swap {
3936        requester: Option<Principal>,
3937        /// How long the candidate may take to register and declare itself
3938        /// ready. `None` uses [`DEFAULT_SWAP_READY_TIMEOUT`].
3939        ready_timeout: Option<Duration>,
3940        /// Answered at cutover or failure; the incumbent's drain follows.
3941        reply: oneshot::Sender<Result<(), SuperviseError>>,
3942    },
3943}
3944
3945#[derive(Debug)]
3946pub enum SuperviseError {
3947    InvalidSpec {
3948        reason: String,
3949    },
3950    Spawn {
3951        program: PathBuf,
3952        source: io::Error,
3953        cgroup_path: Option<PathBuf>,
3954    },
3955    Cgroup {
3956        module_id: String,
3957        source: io::Error,
3958    },
3959    /// CSPRNG failure generating a reserved module's launch nonce. Fail loud rather
3960    /// than spawn a reserved module without its identity binding.
3961    LaunchNonce {
3962        reason: String,
3963    },
3964    Wait {
3965        module_id: String,
3966        source: io::Error,
3967    },
3968    Kill {
3969        module_id: String,
3970        source: io::Error,
3971    },
3972    Forwarding(ForwardingError),
3973    Registry(RegistryError),
3974    ReloadUnavailable {
3975        module_id: String,
3976        reason: String,
3977    },
3978    /// An operator restart/reload was requested for a module that is currently
3979    /// disabled. Restart/reload cycle a *running* module; a disabled module must
3980    /// be explicitly re-enabled (set_enabled(true)) rather than silently started
3981    /// by a restart, so these commands are rejected instead of re-enabling it.
3982    Disabled {
3983        module_id: String,
3984    },
3985    ReloadFailed {
3986        module_id: String,
3987        reason: String,
3988    },
3989    RegistrationStillActive {
3990        module_id: String,
3991        waited: Duration,
3992    },
3993    StatePoisoned {
3994        module_id: Option<String>,
3995    },
3996    CommandClosed {
3997        module_id: String,
3998    },
3999    /// A restart or reload arrived while a swap's candidate was warming. The
4000    /// swap owns the module until it cuts over or fails; a stop or disable
4001    /// would have aborted it instead.
4002    SwapInProgress {
4003        module_id: String,
4004    },
4005    /// A swap was refused before anything was spawned.
4006    SwapRefused {
4007        module_id: String,
4008        reason: SwapRefusal,
4009    },
4010    /// A swap spawned a candidate and gave up on it. The candidate has been
4011    /// killed and its slot freed; the incumbent was left serving and was never
4012    /// drained, except in the one `CutoverLost` case described on that arm.
4013    SwapFailed {
4014        module_id: String,
4015        arm: SwapFailureArm,
4016        detail: String,
4017        /// How the candidate exited, when it exited on its own before the
4018        /// supervisor gave up on it.
4019        candidate_exit: Option<ExitReport>,
4020    },
4021}
4022
4023/// Why a swap was refused before a candidate was spawned.
4024#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4025pub enum SwapRefusal {
4026    /// The module's config does not declare `overlap: "safe"`.
4027    OverlapExclusive,
4028    /// The module is not registered, so there is no incumbent to keep serving
4029    /// and nothing a swap would improve on; a plain restart is the tool.
4030    NotRegistered,
4031    /// The module does not speak the subc wire, so a candidate could never
4032    /// register or declare itself ready.
4033    ProtocolNone,
4034    /// The supervisor lacks the forwarding table (to cut routes over) or the
4035    /// shared handle (to admit the candidate's HELLO) that a swap needs.
4036    NotConfigured,
4037    /// A swap is already open for this module.
4038    AlreadySwapping,
4039}
4040
4041impl SwapRefusal {
4042    pub fn as_str(self) -> &'static str {
4043        match self {
4044            Self::OverlapExclusive => "overlap_exclusive",
4045            Self::NotRegistered => "not_registered",
4046            Self::ProtocolNone => "protocol_none",
4047            Self::NotConfigured => "not_configured",
4048            Self::AlreadySwapping => "already_swapping",
4049        }
4050    }
4051}
4052
4053/// Which failure arm ended a swap. Every arm but one leaves the incumbent
4054/// serving and undrained; see `CutoverLost`.
4055#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4056pub enum SwapFailureArm {
4057    /// The candidate process could not be started.
4058    SpawnFailed,
4059    /// The candidate did not register within the readiness budget.
4060    NeverRegistered,
4061    /// The candidate registered but did not declare itself ready in time.
4062    NeverReady,
4063    /// The candidate exited before cutover.
4064    CandidateExited,
4065    /// The candidate declared itself ready but failed its health probe.
4066    CandidateUnhealthy,
4067    /// An operator stop, disable or retire arrived while the candidate warmed.
4068    /// The candidate was killed and the operator's command then carried out on
4069    /// the incumbent.
4070    Interrupted,
4071    /// The candidate's connection closed at the moment of cutover. If it
4072    /// closed before forwarding moved, the incumbent is untouched. If it closed
4073    /// between the forwarding and registry halves of cutover, forwarding can no
4074    /// longer route to the incumbent, so the module is restarted plainly.
4075    CutoverLost,
4076}
4077
4078impl SwapFailureArm {
4079    pub fn as_str(self) -> &'static str {
4080        match self {
4081            Self::SpawnFailed => "spawn_failed",
4082            Self::NeverRegistered => "never_registered",
4083            Self::NeverReady => "never_ready",
4084            Self::CandidateExited => "candidate_exited",
4085            Self::CandidateUnhealthy => "candidate_unhealthy",
4086            Self::Interrupted => "interrupted",
4087            Self::CutoverLost => "cutover_lost",
4088        }
4089    }
4090}
4091
4092impl fmt::Display for SuperviseError {
4093    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4094        match self {
4095            Self::InvalidSpec { reason } => write!(f, "invalid module spec: {reason}"),
4096            Self::Spawn {
4097                program,
4098                source,
4099                cgroup_path: Some(cgroup_path),
4100            } => write!(
4101                f,
4102                "failed to place module in cgroup '{}' while spawning '{}': {source}",
4103                cgroup_path.display(),
4104                program.display()
4105            ),
4106            Self::Spawn {
4107                program,
4108                source,
4109                cgroup_path: None,
4110            } => write!(
4111                f,
4112                "failed to spawn module '{}': {source}",
4113                program.display()
4114            ),
4115            Self::Cgroup { module_id, source } => {
4116                write!(
4117                    f,
4118                    "failed to prepare cgroup for module '{module_id}': {source}"
4119                )
4120            }
4121            Self::LaunchNonce { reason } => {
4122                write!(
4123                    f,
4124                    "failed to generate reserved-module launch nonce: {reason}"
4125                )
4126            }
4127            Self::Wait { module_id, source } => {
4128                write!(f, "failed to wait for module '{module_id}': {source}")
4129            }
4130            Self::Kill { module_id, source } => {
4131                write!(f, "failed to kill module '{module_id}': {source}")
4132            }
4133            Self::Forwarding(err) => write!(f, "forwarding error: {err}"),
4134            Self::Registry(err) => write!(f, "registry error: {err}"),
4135            Self::ReloadUnavailable { module_id, reason } => {
4136                write!(f, "reload unavailable for module '{module_id}': {reason}")
4137            }
4138            Self::Disabled { module_id } => {
4139                write!(
4140                    f,
4141                    "module '{module_id}' is disabled; enable it before restart or reload"
4142                )
4143            }
4144            Self::ReloadFailed { module_id, reason } => {
4145                write!(f, "reload failed for module '{module_id}': {reason}")
4146            }
4147            Self::RegistrationStillActive { module_id, waited } => write!(
4148                f,
4149                "module '{module_id}' registration remained active after waiting {waited:?}"
4150            ),
4151            Self::StatePoisoned { module_id } => match module_id {
4152                Some(module_id) => {
4153                    write!(f, "supervisor state for module '{module_id}' was poisoned")
4154                }
4155                None => write!(f, "supervisor state was poisoned"),
4156            },
4157            Self::CommandClosed { module_id } => {
4158                write!(
4159                    f,
4160                    "supervisor command channel for module '{module_id}' is closed"
4161                )
4162            }
4163            Self::SwapInProgress { module_id } => write!(
4164                f,
4165                "module '{module_id}' is being swapped; retry once the swap has cut over or failed, or stop the module to abort the swap"
4166            ),
4167            Self::SwapRefused { module_id, reason } => match reason {
4168                SwapRefusal::OverlapExclusive => write!(
4169                    f,
4170                    "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"
4171                ),
4172                SwapRefusal::NotRegistered => write!(
4173                    f,
4174                    "module '{module_id}' is not registered, so there is no serving process to keep while a replacement warms; use a plain restart"
4175                ),
4176                SwapRefusal::ProtocolNone => write!(
4177                    f,
4178                    "module '{module_id}' is protocol: \"none\" and never registers, so a swap could never see its replacement become ready; use a plain restart"
4179                ),
4180                SwapRefusal::NotConfigured => write!(
4181                    f,
4182                    "module '{module_id}' cannot be swapped: the supervisor was built without the forwarding table or shared handle a swap needs"
4183                ),
4184                SwapRefusal::AlreadySwapping => {
4185                    write!(f, "module '{module_id}' is already being swapped")
4186                }
4187            },
4188            Self::SwapFailed {
4189                module_id,
4190                arm,
4191                detail,
4192                ..
4193            } => write!(
4194                f,
4195                "swap of module '{module_id}' failed ({}): {detail}; the running process was left serving",
4196                arm.as_str()
4197            ),
4198        }
4199    }
4200}
4201
4202impl Error for SuperviseError {
4203    fn source(&self) -> Option<&(dyn Error + 'static)> {
4204        match self {
4205            Self::Spawn { source, .. }
4206            | Self::Cgroup { source, .. }
4207            | Self::Wait { source, .. }
4208            | Self::Kill { source, .. } => Some(source),
4209            Self::Forwarding(err) => Some(err),
4210            Self::Registry(err) => Some(err),
4211            Self::LaunchNonce { .. }
4212            | Self::InvalidSpec { .. }
4213            | Self::ReloadUnavailable { .. }
4214            | Self::Disabled { .. }
4215            | Self::ReloadFailed { .. }
4216            | Self::RegistrationStillActive { .. }
4217            | Self::StatePoisoned { .. }
4218            | Self::CommandClosed { .. }
4219            | Self::SwapInProgress { .. }
4220            | Self::SwapRefused { .. }
4221            | Self::SwapFailed { .. } => None,
4222        }
4223    }
4224}
4225
4226pub(crate) fn validate_spec(spec: &ModuleSpec) -> Result<(), SuperviseError> {
4227    if spec.module_id.trim().is_empty() {
4228        return Err(SuperviseError::InvalidSpec {
4229            reason: "module_id must not be empty".to_string(),
4230        });
4231    }
4232
4233    Ok(())
4234}
4235
4236#[derive(Debug, Default)]
4237struct HealthProbeRuntime {
4238    configured_health: Option<HealthConfig>,
4239    registered_connection: Option<crate::ConnectionId>,
4240    advertised: bool,
4241    next_probe_at: Option<Instant>,
4242    probe_index: u64,
4243}
4244
4245fn running_protocol(spec: &ModuleSpec, snapshot: &SharedSnapshot) -> ModuleProtocol {
4246    lock_snapshot(snapshot)
4247        .ok()
4248        .and_then(|state| state.spawned_protocol)
4249        .unwrap_or(spec.protocol)
4250}
4251
4252impl HealthProbeRuntime {
4253    fn refresh_registration(
4254        &mut self,
4255        spec: &ModuleSpec,
4256        runtime: &SupervisorRuntimeConfig,
4257        registry: &Registry,
4258        snapshot: &SharedSnapshot,
4259    ) {
4260        if self.configured_health.as_ref() != Some(&runtime.health) {
4261            self.configured_health = Some(runtime.health.clone());
4262            self.next_probe_at = None;
4263            self.registered_connection = None;
4264            self.probe_index = 0;
4265        }
4266        // A non-wire process never registers. Only an explicitly configured
4267        // HTTP endpoint can arm its health probe; an absent HELLO is not a
4268        // health failure for that kind of process.
4269        if running_protocol(spec, snapshot) == ModuleProtocol::None {
4270            self.registered_connection = None;
4271            self.advertised = runtime.health.http.is_some();
4272            if !self.advertised {
4273                self.next_probe_at = None;
4274                let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4275                    let unknown = ModuleHealthStatus::default();
4276                    if state.health != unknown {
4277                        state.health = unknown;
4278                    }
4279                });
4280            } else if self.next_probe_at.is_none() {
4281                self.next_probe_at = Some(
4282                    Instant::now()
4283                        + jittered_health_delay(&spec.module_id, 0, runtime.health.cadence),
4284                );
4285            }
4286            return;
4287        }
4288
4289        let registration = match registry.get_module(&spec.module_id) {
4290            Ok(registration) => registration,
4291            Err(err) => {
4292                warn!(module_id = %spec.module_id, error = %err, "health prober could not read registry");
4293                self.advertised = false;
4294                self.next_probe_at = None;
4295                return;
4296            }
4297        };
4298
4299        let Some(registration) = registration else {
4300            self.registered_connection = None;
4301            self.advertised = false;
4302            self.next_probe_at = None;
4303            return;
4304        };
4305
4306        let advertised = registration
4307            .control_ops
4308            .iter()
4309            .any(|op| op == MODULE_CONTROL_OP_HEALTH_CHECK);
4310        if !advertised {
4311            self.registered_connection = Some(registration.connection_id);
4312            self.advertised = false;
4313            self.next_probe_at = None;
4314            let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4315                if state.health.status != SupervisorHealthStatus::Unknown
4316                    || state.health.consecutive_failures != 0
4317                    || state.health.last_probe_ms.is_some()
4318                    || state.health.detail.is_some()
4319                    || state.health.metrics.is_some()
4320                {
4321                    state.health.status = SupervisorHealthStatus::Unknown;
4322                    state.health.consecutive_failures = 0;
4323                    state.health.last_probe_ms = None;
4324                    state.health.detail = None;
4325                    state.health.metrics = None;
4326                }
4327            });
4328            return;
4329        }
4330
4331        let reregistered = self.registered_connection != Some(registration.connection_id);
4332        self.registered_connection = Some(registration.connection_id);
4333        self.advertised = true;
4334        if reregistered || self.next_probe_at.is_none() {
4335            self.probe_index = 0;
4336            self.next_probe_at = Some(
4337                Instant::now() + jittered_health_delay(&spec.module_id, 0, runtime.health.cadence),
4338            );
4339            let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4340                state.health.status = SupervisorHealthStatus::Unknown;
4341                state.health.consecutive_failures = 0;
4342                state.health.detail = None;
4343                state.health.metrics = None;
4344            });
4345        }
4346    }
4347
4348    fn wake_after(&self) -> Option<Duration> {
4349        if !self.advertised {
4350            return None;
4351        }
4352        self.next_probe_at
4353            .map(|next| next.saturating_duration_since(Instant::now()))
4354    }
4355
4356    fn due(&self) -> bool {
4357        self.advertised
4358            && self
4359                .next_probe_at
4360                .is_some_and(|next| Instant::now() >= next)
4361    }
4362
4363    fn schedule_next(&mut self, spec: &ModuleSpec, cadence: Duration) {
4364        self.probe_index = self.probe_index.wrapping_add(1);
4365        self.next_probe_at = Some(
4366            Instant::now() + jittered_health_delay(&spec.module_id, self.probe_index, cadence),
4367        );
4368    }
4369}
4370
4371/// What a failed health probe actually OBSERVED, kept apart from how it reads.
4372///
4373/// This was a struct with a single `message: String`, and every one of the
4374/// fifteen construction sites collapsed into it. Each site knows exactly what it
4375/// saw -- the lane is gone, the module did not answer in time, the module
4376/// answered with the wrong thing -- and `handle_health_probe_failure` then
4377/// treated all of them identically: increment a counter, compare to a threshold,
4378/// restart the module. THE DISTINCTION EXISTED AT EVERY CALL SITE AND WAS
4379/// DESTROYED BEFORE THE DECISION THAT NEEDED IT.
4380///
4381/// The distinction that matters is not severity, it is EVIDENTIAL WEIGHT:
4382///
4383/// * `LaneDead` is PROOF. The module's control connection is gone; nothing will
4384///   answer on it again.
4385/// * `NoAnswer` is ABSENCE OF EVIDENCE. It is consistent with a wedged module
4386///   AND with a perfectly healthy one that lost a CPU race -- which is what
4387///   happens under machine load, and is how this supervisor killed a healthy
4388///   module three times in one day.
4389/// * `BadAnswer` proves the module is ALIVE. It replied; the reply was wrong.
4390///   Restarting on it is defensible, but it is not the silence case and should
4391///   never be counted as one.
4392/// * `Misconfigured` is a daemon-side fault. The module has not been asked
4393///   anything, so it cannot be evidence about the module at all.
4394///
4395/// The asymmetry is the whole point: under saturation the WEAKEST signal is the
4396/// one that fires most often, and while every variant collapsed into one string
4397/// it carried the same weight as the strongest.
4398///
4399/// LIVE BEHAVIOUR TODAY, stated here because this doc block describes the
4400/// DESIGN and a reader stopping at it gets the build backwards: the restart
4401/// decision does NOT yet consult this classification -- consecutive `NoAnswer`
4402/// probes still increment the failure streak and drive escalation at the
4403/// threshold (see `is_proof_of_death` below for why that is deliberate and
4404/// what gates the change). Absence of evidence restarts modules today.
4405#[derive(Debug)]
4406enum HealthProbeEvidence {
4407    /// The module's control lane is gone. Proof of death.
4408    LaneDead,
4409    /// No reply within the deadline. Proves nothing about the module's state.
4410    NoAnswer,
4411    /// The module replied, but not with a usable health report. Proves it is alive.
4412    BadAnswer,
4413    /// The daemon could not ask. Says nothing about the module.
4414    Misconfigured,
4415}
4416
4417#[derive(Debug)]
4418struct HealthProbeError {
4419    evidence: HealthProbeEvidence,
4420    message: String,
4421}
4422
4423impl HealthProbeError {
4424    fn lane_dead(message: impl Into<String>) -> Self {
4425        Self::with(HealthProbeEvidence::LaneDead, message)
4426    }
4427
4428    fn no_answer(message: impl Into<String>) -> Self {
4429        Self::with(HealthProbeEvidence::NoAnswer, message)
4430    }
4431
4432    fn bad_answer(message: impl Into<String>) -> Self {
4433        Self::with(HealthProbeEvidence::BadAnswer, message)
4434    }
4435
4436    fn misconfigured(message: impl Into<String>) -> Self {
4437        Self::with(HealthProbeEvidence::Misconfigured, message)
4438    }
4439
4440    fn with(evidence: HealthProbeEvidence, message: impl Into<String>) -> Self {
4441        Self {
4442            evidence,
4443            message: message.into(),
4444        }
4445    }
4446
4447    /// Whether this observation is proof the module cannot serve.
4448    ///
4449    /// Only `LaneDead` qualifies. `NoAnswer` is deliberately excluded: it is the
4450    /// variant that fires under CPU starvation, and treating it as proof is the
4451    /// defect this enum exists to make impossible to reintroduce silently.
4452    ///
4453    /// NOT YET CONSULTED BY THE RESTART DECISION, deliberately. Requiring proof
4454    /// to restart also needs a bound for the case it excludes -- a genuinely
4455    /// wedged module, alive but never answering -- and that bound must come from
4456    /// the distribution of real late-answer latencies, which nothing measures
4457    /// yet. Landing the classification first makes the later change a one-line
4458    /// decision against evidence that already exists, rather than two unproven
4459    /// changes at once.
4460    #[allow(dead_code)]
4461    fn is_proof_of_death(&self) -> bool {
4462        matches!(self.evidence, HealthProbeEvidence::LaneDead)
4463    }
4464
4465    /// Short stable label for logs and the health snapshot.
4466    ///
4467    /// An operator reading `ck health` currently cannot tell "the module is gone"
4468    /// from "the module did not answer in five seconds", because both render as
4469    /// prose in the same field. These labels are what make the two
4470    /// distinguishable at a glance, and they are what a later restart-policy
4471    /// change will be argued from.
4472    fn label(&self) -> &'static str {
4473        match self.evidence {
4474            HealthProbeEvidence::LaneDead => "lane-dead",
4475            HealthProbeEvidence::NoAnswer => "no-answer",
4476            HealthProbeEvidence::BadAnswer => "bad-answer",
4477            HealthProbeEvidence::Misconfigured => "daemon-misconfigured",
4478        }
4479    }
4480}
4481
4482impl fmt::Display for HealthProbeError {
4483    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4484        f.write_str(&self.message)
4485    }
4486}
4487
4488async fn run_health_probe_cycle(
4489    spec: &ModuleSpec,
4490    runtime: &SupervisorRuntimeConfig,
4491    registry: &Registry,
4492    process_liveness: &SupervisorProcessLiveness,
4493    snapshot: &SharedSnapshot,
4494    child: &mut Option<SupervisedChild>,
4495) {
4496    let now_ms = unix_ms_now();
4497    let http = (running_protocol(spec, snapshot) == ModuleProtocol::None)
4498        .then_some(runtime.health.http.as_deref())
4499        .flatten();
4500    let result = match http {
4501        Some(url) => probe_http_health(url, runtime.health.deadline).await,
4502        None => probe_module_health(&spec.module_id, runtime, None).await,
4503    };
4504    match result {
4505        Ok(report) => {
4506            handle_health_report(
4507                spec,
4508                runtime,
4509                registry,
4510                process_liveness,
4511                snapshot,
4512                child,
4513                report,
4514                now_ms,
4515            )
4516            .await;
4517        }
4518        Err(err) => {
4519            if http.is_some() {
4520                let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4521                    state.health.status = SupervisorHealthStatus::Failing;
4522                });
4523            }
4524            handle_health_probe_failure(
4525                spec,
4526                runtime,
4527                registry,
4528                process_liveness,
4529                snapshot,
4530                child,
4531                err,
4532                now_ms,
4533            )
4534            .await;
4535        }
4536    }
4537}
4538
4539pub(crate) struct HttpProbeTarget<'a> {
4540    address: std::net::SocketAddr,
4541    localhost: bool,
4542    authority: &'a str,
4543    path: String,
4544}
4545
4546/// Resolve only literal loopback endpoints, without DNS, redirects, proxies,
4547/// or TLS. A URL cannot turn a local health check into an outbound connection.
4548pub(crate) fn parse_http_probe_url(url: &str) -> Result<HttpProbeTarget<'_>, String> {
4549    if url.bytes().any(|b| b <= b' ' || b == 127) || url.contains('#') {
4550        return Err("must not contain whitespace, controls, or a fragment".into());
4551    }
4552    let rest = url
4553        .strip_prefix("http://")
4554        .ok_or("must use plain http://")?;
4555    let split = rest.find(['/', '?']).unwrap_or(rest.len());
4556    let (authority, suffix) = rest.split_at(split);
4557    let (host, port) = if let Some(rest) = authority.strip_prefix("[::1]") {
4558        ("::1", rest)
4559    } else {
4560        let split = authority.find(':').unwrap_or(authority.len());
4561        authority.split_at(split)
4562    };
4563    let ip: std::net::IpAddr = match host {
4564        "127.0.0.1" | "localhost" => std::net::Ipv4Addr::LOCALHOST.into(),
4565        "::1" => std::net::Ipv6Addr::LOCALHOST.into(),
4566        _ => return Err("host must be 127.0.0.1, [::1], or localhost".into()),
4567    };
4568    let port = if port.is_empty() {
4569        80
4570    } else {
4571        port.strip_prefix(':')
4572            .and_then(|p| p.parse::<u16>().ok())
4573            .filter(|p| *p > 0)
4574            .ok_or("must have a valid nonzero TCP port")?
4575    };
4576    let path = if suffix.is_empty() {
4577        "/".into()
4578    } else if suffix.starts_with('?') {
4579        format!("/{suffix}")
4580    } else {
4581        suffix.into()
4582    };
4583    Ok(HttpProbeTarget {
4584        address: std::net::SocketAddr::new(ip, port),
4585        localhost: host == "localhost",
4586        authority,
4587        path,
4588    })
4589}
4590
4591async fn probe_http_health(
4592    url: &str,
4593    deadline: Duration,
4594) -> Result<HealthReport, HealthProbeError> {
4595    use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader};
4596    let target = parse_http_probe_url(url).map_err(HealthProbeError::misconfigured)?;
4597    // Keep partial diagnostics outside the timed future so cancellation does
4598    // not discard a status line or body bytes already received.
4599    let mut response_status = String::new();
4600    let mut body = Vec::new();
4601    let probe = async {
4602        // Resolve localhost ourselves so a hosts-file override cannot turn
4603        // this into an outbound request, while IPv6-only local servers work.
4604        let connection = match tokio::net::TcpStream::connect(target.address).await {
4605            Err(_) if target.localhost => {
4606                tokio::net::TcpStream::connect((
4607                    std::net::Ipv6Addr::LOCALHOST,
4608                    target.address.port(),
4609                ))
4610                .await
4611            }
4612            result => result,
4613        };
4614        let mut stream = connection.map_err(|error| {
4615            HealthProbeError::no_answer(format!("HTTP connect failed: {error}"))
4616        })?;
4617        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
4618            .map_err(|error| HealthProbeError::no_answer(format!("HTTP write failed: {error}")))?;
4619        let mut reader = BufReader::new(stream);
4620        let mut budget = 16 * 1024;
4621        let status = http_line(&mut reader, &mut budget).await?;
4622        let mut words = status.split_ascii_whitespace();
4623        let version = words.next();
4624        let code = words
4625            .next()
4626            .filter(|word| word.len() == 3)
4627            .and_then(|word| word.parse::<u16>().ok());
4628        if !matches!(version, Some("HTTP/1.1" | "HTTP/1.0"))
4629            || !code.is_some_and(|code| (100..600).contains(&code))
4630        {
4631            return Err(HealthProbeError::bad_answer(format!(
4632                "invalid HTTP status: {status}"
4633            )));
4634        }
4635        let code = code.expect("validated status code");
4636        response_status = status.clone();
4637        let mut length = None;
4638        let mut chunked = false;
4639        loop {
4640            let line = http_line(&mut reader, &mut budget).await?;
4641            if line.is_empty() {
4642                break;
4643            }
4644            if let Some((name, value)) = line.split_once(':') {
4645                if name.eq_ignore_ascii_case("content-length") {
4646                    length = Some(value.trim().parse::<u64>().map_err(|_| {
4647                        HealthProbeError::bad_answer("invalid HTTP Content-Length")
4648                    })?);
4649                } else if name.eq_ignore_ascii_case("transfer-encoding") {
4650                    chunked = value.trim().eq_ignore_ascii_case("chunked");
4651                }
4652            }
4653        }
4654        if chunked {
4655            while body.len() < 200 {
4656                let line = http_line(&mut reader, &mut budget).await?;
4657                let size = u64::from_str_radix(line.split(';').next().unwrap_or("").trim(), 16)
4658                    .map_err(|_| HealthProbeError::bad_answer("invalid HTTP chunk size"))?;
4659                if size == 0 {
4660                    break;
4661                }
4662                let count = size.min((200 - body.len()) as u64) as usize;
4663                let start = body.len();
4664                (&mut reader)
4665                    .take(count as u64)
4666                    .read_to_end(&mut body)
4667                    .await
4668                    .map_err(|error| {
4669                        HealthProbeError::no_answer(format!("HTTP body read failed: {error}"))
4670                    })?;
4671                if body.len() - start != count {
4672                    return Err(HealthProbeError::bad_answer("truncated HTTP chunk"));
4673                }
4674                if size > count as u64 || body.len() == 200 {
4675                    break;
4676                }
4677                if !http_line(&mut reader, &mut budget).await?.is_empty() {
4678                    return Err(HealthProbeError::bad_answer("invalid HTTP chunk delimiter"));
4679                }
4680            }
4681        } else {
4682            reader
4683                .take(length.unwrap_or(200).min(200))
4684                .read_to_end(&mut body)
4685                .await
4686                .map_err(|error| {
4687                    HealthProbeError::no_answer(format!("HTTP body read failed: {error}"))
4688                })?;
4689        }
4690        if (200..300).contains(&code) {
4691            Ok(HealthReport::ok())
4692        } else {
4693            Err(HealthProbeError::bad_answer(
4694                "HTTP health endpoint returned non-2xx",
4695            ))
4696        }
4697    };
4698    let mut result = timeout(deadline, probe).await.unwrap_or_else(|_| {
4699        Err(HealthProbeError::no_answer(format!(
4700            "HTTP probe timed out after {deadline:?}"
4701        )))
4702    });
4703    if let Err(error) = &mut result {
4704        if !response_status.is_empty() {
4705            error.message = format!(
4706                "{}; {response_status}: {}",
4707                error.message,
4708                String::from_utf8_lossy(&body)
4709            );
4710        }
4711    }
4712    result
4713}
4714
4715async fn http_line(
4716    reader: &mut tokio::io::BufReader<tokio::net::TcpStream>,
4717    remaining: &mut usize,
4718) -> Result<String, HealthProbeError> {
4719    use tokio::io::{AsyncBufReadExt, AsyncReadExt};
4720    let mut line = Vec::new();
4721    (&mut *reader)
4722        .take(*remaining as u64)
4723        .read_until(b'\n', &mut line)
4724        .await
4725        .map_err(|error| {
4726            HealthProbeError::no_answer(format!("HTTP header read failed: {error}"))
4727        })?;
4728    *remaining -= line.len();
4729    if !line.ends_with(b"\r\n") {
4730        return Err(HealthProbeError::bad_answer(
4731            "HTTP headers are incomplete or exceed 16 KiB",
4732        ));
4733    }
4734    line.truncate(line.len() - 2);
4735    String::from_utf8(line).map_err(|_| HealthProbeError::bad_answer("HTTP header is not UTF-8"))
4736}
4737
4738async fn probe_module_health(
4739    module_id: &str,
4740    runtime: &SupervisorRuntimeConfig,
4741    drain_deadline: Option<Instant>,
4742) -> Result<HealthReport, HealthProbeError> {
4743    let Some(forwarding) = runtime.forwarding.as_ref() else {
4744        return Err(HealthProbeError::misconfigured(
4745            "supervisor was not configured with a forwarding table",
4746        ));
4747    };
4748    let probe_started_at = Instant::now();
4749    let mut deadline = probe_started_at + runtime.health.deadline;
4750    if let Some(drain_deadline) = drain_deadline {
4751        deadline = deadline.min(drain_deadline);
4752    }
4753    let pending = if drain_deadline.is_some() {
4754        forwarding.begin_drain_health_probe_rpc_for(
4755            module_id,
4756            MODULE_CONTROL_OP_HEALTH_CHECK,
4757            probe_started_at,
4758            deadline,
4759        )
4760    } else {
4761        forwarding.begin_health_probe_rpc_for(
4762            module_id,
4763            MODULE_CONTROL_OP_HEALTH_CHECK,
4764            probe_started_at,
4765            deadline,
4766        )
4767    }
4768    .map_err(|err| {
4769        // The endpoint is not registered, so there is no live control lane to
4770        // ask. That is the module being absent, not slow.
4771        HealthProbeError::lane_dead(format!("failed to begin health.check RPC: {err}"))
4772    })?;
4773    await_health_probe(forwarding, pending, deadline, runtime.health.deadline).await
4774}
4775
4776/// [`probe_module_health`] for one endpoint rather than the id's active one.
4777///
4778/// A swap probes two processes that no by-id lookup reaches: its candidate
4779/// before cutover, and its superseded incumbent (for busy gauges) while the
4780/// incumbent drains. `deadline_cap` bounds the probe the way a drain deadline
4781/// bounds the by-id drain probe.
4782async fn probe_endpoint_health(
4783    endpoint: crate::ModuleEndpointId,
4784    runtime: &SupervisorRuntimeConfig,
4785    deadline_cap: Option<Instant>,
4786) -> Result<HealthReport, HealthProbeError> {
4787    let Some(forwarding) = runtime.forwarding.as_ref() else {
4788        return Err(HealthProbeError::misconfigured(
4789            "supervisor was not configured with a forwarding table",
4790        ));
4791    };
4792    let probe_started_at = Instant::now();
4793    let mut deadline = probe_started_at + runtime.health.deadline;
4794    if let Some(cap) = deadline_cap {
4795        deadline = deadline.min(cap);
4796    }
4797    let pending = forwarding
4798        .begin_endpoint_health_probe_rpc_for(
4799            endpoint,
4800            MODULE_CONTROL_OP_HEALTH_CHECK,
4801            probe_started_at,
4802            deadline,
4803        )
4804        .map_err(|err| {
4805            HealthProbeError::lane_dead(format!("failed to begin health.check RPC: {err}"))
4806        })?;
4807    await_health_probe(forwarding, pending, deadline, runtime.health.deadline).await
4808}
4809
4810/// Send a begun health probe and classify its answer.
4811async fn await_health_probe(
4812    forwarding: &ForwardingTable,
4813    pending: PendingModuleControlRpc,
4814    deadline: Instant,
4815    probe_budget: Duration,
4816) -> Result<HealthReport, HealthProbeError> {
4817    let PendingModuleControlRpc {
4818        endpoint,
4819        module_sink,
4820        negotiated_ver,
4821        corr,
4822        receiver,
4823    } = pending;
4824    let body = serde_json::to_vec(&ModuleControlRequest::HealthCheck {}).map_err(|err| {
4825        HealthProbeError::misconfigured(format!("failed to encode health.check: {err}"))
4826    })?;
4827    let frame = Frame::build_with_version(
4828        negotiated_ver,
4829        FrameType::Request,
4830        control_flags(),
4831        0,
4832        0,
4833        corr,
4834        body,
4835    )
4836    .map_err(|err| {
4837        HealthProbeError::misconfigured(format!("failed to build health.check frame: {err}"))
4838    })?;
4839
4840    // The enqueue itself must be bounded by the probe deadline: FrameSink.send
4841    // blocks waiting for capacity when the module's egress queue is full, and an
4842    // unbounded await here freezes the whole supervision actor (it stops polling
4843    // Child::wait and supervisor commands), making the module unrecoverable
4844    // in-band. On timeout the probe fails like any transport failure.
4845    match timeout_at(deadline, module_sink.send(frame)).await {
4846        Ok(Ok(())) => {}
4847        Ok(Err(err)) => {
4848            let _ = forwarding.cancel_module_control_rpc(endpoint, corr);
4849            // A closed sink means the module's egress channel is gone -- the
4850            // receiving half is dropped when its connection tears down. Proof.
4851            return Err(HealthProbeError::lane_dead(format!(
4852                "failed to send health.check: {err}"
4853            )));
4854        }
4855        Err(_elapsed) => {
4856            let _ = forwarding.cancel_module_control_rpc(endpoint, corr);
4857            // A full egress queue means the module is not draining its socket, which
4858            // is consistent with a wedged module AND with one whose reader is merely
4859            // starved. Silence, not proof.
4860            return Err(HealthProbeError::no_answer(
4861                "health.check send timed out before enqueue (module egress full)",
4862            ));
4863        }
4864    }
4865
4866    match timeout_at(deadline, receiver).await {
4867        // Each arm records WHAT WAS OBSERVED. Four of them are the module
4868        // demonstrably answering -- rejected, non-health, malformed, wrong op --
4869        // and those prove it is alive even though the probe failed.
4870        Ok(Ok(ModuleControlRpcOutcome::Response(response))) => {
4871            response.health_report().ok_or_else(|| {
4872                HealthProbeError::bad_answer("health.check RPC returned a non-health response")
4873            })
4874        }
4875        Ok(Ok(ModuleControlRpcOutcome::Rejected(body))) => Err(HealthProbeError::bad_answer(
4876            format!("health.check rejected: {}", body.message),
4877        )),
4878        Ok(Ok(ModuleControlRpcOutcome::ModuleGone(message))) => {
4879            Err(HealthProbeError::lane_dead(message))
4880        }
4881        Ok(Ok(ModuleControlRpcOutcome::MalformedResponse(message))) => {
4882            Err(HealthProbeError::bad_answer(message))
4883        }
4884        Ok(Ok(ModuleControlRpcOutcome::UnexpectedOp { expected, actual })) => {
4885            Err(HealthProbeError::bad_answer(format!(
4886                "expected module-control op '{expected}', got '{actual}'"
4887            )))
4888        }
4889        // A reply that crosses the deadline before this waiter observes it is
4890        // still proof of life. The forwarding path records its end-to-end latency
4891        // before delivering this classification.
4892        Ok(Ok(ModuleControlRpcOutcome::DeadlineElapsed)) => Err(HealthProbeError::bad_answer(
4893            "module answered health.check after its daemon deadline",
4894        )),
4895        Ok(Err(_)) => Err(HealthProbeError::misconfigured(
4896            "health.check waiter was canceled before the module responded",
4897        )),
4898        Err(_) => {
4899            let _ = forwarding.tombstone_health_probe_rpc(endpoint, corr);
4900            Err(HealthProbeError::no_answer(format!(
4901                "module did not answer health.check within {probe_budget:?}"
4902            )))
4903        }
4904    }
4905}
4906
4907#[allow(clippy::too_many_arguments)]
4908async fn handle_health_report(
4909    spec: &ModuleSpec,
4910    runtime: &SupervisorRuntimeConfig,
4911    registry: &Registry,
4912    process_liveness: &SupervisorProcessLiveness,
4913    snapshot: &SharedSnapshot,
4914    child: &mut Option<SupervisedChild>,
4915    report: HealthReport,
4916    now_ms: u64,
4917) {
4918    let status = supervisor_health_status(report.status);
4919    let detail = report.detail.clone();
4920    let metrics = truncate_health_metrics(report.metrics);
4921    let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4922        state.health.status = status;
4923        state.health.last_probe_ms = Some(now_ms);
4924        state.health.detail = detail.clone();
4925        state.health.metrics = metrics.clone();
4926        state.health.consecutive_failures = 0;
4927    });
4928
4929    let action = match report.status {
4930        HealthStatus::Ok => return,
4931        HealthStatus::Degraded => runtime.health.on_degraded,
4932        HealthStatus::Failing => runtime.health.on_failing,
4933    };
4934    apply_l3_health_action(
4935        spec,
4936        runtime,
4937        registry,
4938        process_liveness,
4939        snapshot,
4940        child,
4941        status,
4942        detail.as_deref(),
4943        action,
4944        now_ms,
4945    )
4946    .await;
4947}
4948
4949#[allow(clippy::too_many_arguments)]
4950async fn handle_health_probe_failure(
4951    spec: &ModuleSpec,
4952    runtime: &SupervisorRuntimeConfig,
4953    registry: &Registry,
4954    process_liveness: &SupervisorProcessLiveness,
4955    snapshot: &SharedSnapshot,
4956    child: &mut Option<SupervisedChild>,
4957    err: HealthProbeError,
4958    now_ms: u64,
4959) {
4960    let threshold = runtime.health.failure_threshold.max(1);
4961    let mut failures = 0;
4962    // Carry the evidence class into the operator-visible detail. Without it,
4963    // "module did not answer within 5s" and "the control lane is gone" are two
4964    // prose strings in the same field, and the reader has to know the codebase to
4965    // tell which one is proof of anything.
4966    let detail = format!("[{}] {err}", err.label());
4967    let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4968        // A failed wire probe invalidates the last report, even before the
4969        // restart threshold. HTTP probes already mark failures as Failing.
4970        if state.spawned_protocol.unwrap_or(spec.protocol) == ModuleProtocol::Subc {
4971            state.health.status = SupervisorHealthStatus::Unknown;
4972        }
4973        state.health.last_probe_ms = Some(now_ms);
4974        state.health.consecutive_failures = state.health.consecutive_failures.saturating_add(1);
4975        state.health.detail = Some(detail.clone());
4976        state.health.metrics = None;
4977        failures = state.health.consecutive_failures;
4978    });
4979
4980    if failures < threshold {
4981        warn!(
4982            module_id = %spec.module_id,
4983            consecutive_failures = failures,
4984            threshold,
4985            evidence = err.label(),
4986            detail = %detail,
4987            "health.check probe failed"
4988        );
4989        return;
4990    }
4991
4992    let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4993        state.state = ModuleState::Unresponsive;
4994        state.health.status = SupervisorHealthStatus::Unresponsive;
4995    });
4996    // The evidence class is logged at the kill site because this is the line an
4997    // operator reads after an unexplained restart. A streak of `no-answer` under
4998    // machine load is the known false-positive shape; a `lane-dead` is not.
4999    if runtime.health.critical {
5000        error!(
5001            module_id = %spec.module_id,
5002            status = "unresponsive",
5003            evidence = err.label(),
5004            detail = %detail,
5005            "critical module health alert"
5006        );
5007    } else {
5008        warn!(
5009            module_id = %spec.module_id,
5010            status = "unresponsive",
5011            evidence = err.label(),
5012            detail = %detail,
5013            "module health threshold breached"
5014        );
5015    }
5016    if let Err(err) = health_restart_child(
5017        spec,
5018        runtime,
5019        registry,
5020        process_liveness,
5021        snapshot,
5022        child,
5023        SupervisorHealthStatus::Unresponsive,
5024        Some(&detail),
5025        now_ms,
5026    )
5027    .await
5028    {
5029        error!(module_id = %spec.module_id, error = %err, "health-triggered restart failed");
5030    }
5031}
5032
5033#[allow(clippy::too_many_arguments)]
5034async fn apply_l3_health_action(
5035    spec: &ModuleSpec,
5036    runtime: &SupervisorRuntimeConfig,
5037    registry: &Registry,
5038    process_liveness: &SupervisorProcessLiveness,
5039    snapshot: &SharedSnapshot,
5040    child: &mut Option<SupervisedChild>,
5041    status: SupervisorHealthStatus,
5042    detail: Option<&str>,
5043    action: HealthAction,
5044    now_ms: u64,
5045) {
5046    record_health_action(snapshot, &spec.module_id, action.to_string(), now_ms);
5047    match action {
5048        HealthAction::Report => {
5049            info!(
5050                module_id = %spec.module_id,
5051                status = ?status,
5052                detail,
5053                "module reported non-ok health"
5054            );
5055        }
5056        HealthAction::Alert => {
5057            error!(
5058                module_id = %spec.module_id,
5059                status = ?status,
5060                detail,
5061                "module health alert"
5062            );
5063        }
5064        HealthAction::Restart => {
5065            if let Err(err) = health_restart_child(
5066                spec,
5067                runtime,
5068                registry,
5069                process_liveness,
5070                snapshot,
5071                child,
5072                status,
5073                detail,
5074                now_ms,
5075            )
5076            .await
5077            {
5078                error!(module_id = %spec.module_id, error = %err, "health-triggered restart failed");
5079            }
5080        }
5081    }
5082}
5083
5084#[allow(clippy::too_many_arguments)]
5085async fn health_restart_child(
5086    spec: &ModuleSpec,
5087    runtime: &SupervisorRuntimeConfig,
5088    registry: &Registry,
5089    process_liveness: &SupervisorProcessLiveness,
5090    snapshot: &SharedSnapshot,
5091    child: &mut Option<SupervisedChild>,
5092    status: SupervisorHealthStatus,
5093    detail: Option<&str>,
5094    now_ms: u64,
5095) -> Result<(), SuperviseError> {
5096    let (enabled, schedule) = {
5097        let mut state = lock_snapshot(snapshot)?;
5098        let enabled = state.enabled;
5099        let schedule = if enabled {
5100            state.next_crash_restart(&runtime.restart_policy, Instant::now())
5101        } else {
5102            None
5103        };
5104        (enabled, schedule)
5105    };
5106
5107    if !enabled {
5108        return Err(SuperviseError::Disabled {
5109            module_id: spec.module_id.clone(),
5110        });
5111    }
5112
5113    if schedule.is_none() {
5114        record_health_action(snapshot, &spec.module_id, "failed".to_string(), now_ms);
5115        error!(
5116            module_id = %spec.module_id,
5117            status = ?status,
5118            detail,
5119            max_restarts = runtime.restart_policy.max_restarts,
5120            window_secs = runtime.restart_policy.window.as_secs(),
5121            reason = %runtime.restart_policy.budget_exhausted_detail(),
5122            "health restart budget exhausted; marking module failed"
5123        );
5124        let stop_notice = begin_forwarding_drain_if_configured(
5125            spec,
5126            runtime,
5127            registry,
5128            snapshot,
5129            Some(true),
5130            RouteCloseReason::Disable,
5131        )
5132        .await?;
5133        update_snapshot(snapshot, Some(&spec.module_id), |state| {
5134            state.drain_disposition_detail = Some(runtime.restart_policy.budget_exhausted_detail());
5135        })?;
5136        drain_optional_child(
5137            &spec.module_id,
5138            spec.protocol,
5139            stop_notice,
5140            registry,
5141            runtime.forwarding.as_deref(),
5142            snapshot,
5143            &runtime.terminal_ring,
5144            &runtime.spawn_events,
5145            child,
5146            runtime.drain_timeout,
5147            ModuleState::Failed,
5148            Some(true),
5149        )
5150        .await?;
5151        process_liveness.untrack_if_current(&spec.module_id, snapshot);
5152        return Ok(());
5153    }
5154
5155    let schedule = schedule.expect("a health restart must have a crash-restart schedule");
5156    let mut restart_count = 0;
5157    update_snapshot(snapshot, Some(&spec.module_id), |state| {
5158        restart_count = state.crash_restarts.len();
5159        state.state = ModuleState::Unresponsive;
5160        state.health.status = status;
5161        state.health.last_action = Some(HealthAction::Restart.to_string());
5162        state.health.last_action_ms = Some(now_ms);
5163    })?;
5164    warn!(
5165        module_id = %spec.module_id,
5166        status = ?status,
5167        detail,
5168        restart_count,
5169        restart_in_window = schedule.restart_in_window,
5170        delay_ms = schedule.delay.as_millis() as u64,
5171        "health-triggered module restart"
5172    );
5173
5174    let stop_notice = begin_forwarding_drain_if_configured(
5175        spec,
5176        runtime,
5177        registry,
5178        snapshot,
5179        Some(true),
5180        RouteCloseReason::Restart,
5181    )
5182    .await?;
5183    drain_optional_child(
5184        &spec.module_id,
5185        spec.protocol,
5186        stop_notice,
5187        registry,
5188        runtime.forwarding.as_deref(),
5189        snapshot,
5190        &runtime.terminal_ring,
5191        &runtime.spawn_events,
5192        child,
5193        runtime.drain_timeout,
5194        ModuleState::Restarting,
5195        Some(true),
5196    )
5197    .await?;
5198    schedule_respawn(
5199        runtime,
5200        snapshot,
5201        &spec.module_id,
5202        schedule.delay,
5203        RespawnKind::Spawn,
5204    )
5205}
5206
5207fn cancel_deferred_reload(runtime: &SupervisorRuntimeConfig, module_id: &str, reason: &str) {
5208    if let Some(reply) = runtime
5209        .deferred_reload_reply
5210        .lock()
5211        .unwrap_or_else(|p| p.into_inner())
5212        .take()
5213    {
5214        let _ = reply.send(Err(SuperviseError::ReloadFailed {
5215            module_id: module_id.to_string(),
5216            reason: reason.to_string(),
5217        }));
5218    }
5219}
5220
5221fn schedule_respawn(
5222    runtime: &SupervisorRuntimeConfig,
5223    snapshot: &SharedSnapshot,
5224    module_id: &str,
5225    delay: Duration,
5226    kind: RespawnKind,
5227) -> Result<(), SuperviseError> {
5228    cancel_deferred_reload(runtime, module_id, "respawn superseded by another restart");
5229    update_snapshot(snapshot, Some(module_id), |state| {
5230        state.respawn_pending = true
5231    })?;
5232    *runtime
5233        .scheduled_respawn
5234        .lock()
5235        .unwrap_or_else(|p| p.into_inner()) = Some(PendingRespawn {
5236        deadline: Instant::now() + delay,
5237        kind,
5238    });
5239    Ok(())
5240}
5241
5242fn record_health_action(snapshot: &SharedSnapshot, module_id: &str, action: String, now_ms: u64) {
5243    let _ = update_snapshot(snapshot, Some(module_id), |state| {
5244        state.health.last_action = Some(action);
5245        state.health.last_action_ms = Some(now_ms);
5246    });
5247}
5248
5249fn supervisor_health_status(status: HealthStatus) -> SupervisorHealthStatus {
5250    match status {
5251        HealthStatus::Ok => SupervisorHealthStatus::Ok,
5252        HealthStatus::Degraded => SupervisorHealthStatus::Degraded,
5253        HealthStatus::Failing => SupervisorHealthStatus::Failing,
5254    }
5255}
5256
5257/// Caps the metrics blob stored in the cached supervisor snapshot, which is
5258/// returned to every `supervisor.list` and `supervisor.health` caller.
5259///
5260/// This cap is deliberately NOT applied on the one-shot `supervisor.health_probe`
5261/// path: that request exists to return a module's complete metrics object, and
5262/// `ck health <module-id>` documents it as the way to see what the cached view
5263/// truncates. The asymmetry is the feature.
5264///
5265/// So a new caller must decide which side it is on rather than assume the cap is
5266/// universal. Reaching for it on a fresh-probe path would silently reintroduce
5267/// the truncation that path exists to avoid.
5268fn truncate_health_metrics(metrics: Option<Value>) -> Option<Value> {
5269    let metrics = metrics?;
5270    match serde_json::to_vec(&metrics) {
5271        Ok(encoded) if encoded.len() > MAX_HEALTH_METRICS_BYTES => Some(serde_json::json!({
5272            "truncated": true,
5273            "original_bytes": encoded.len(),
5274        })),
5275        Ok(_) | Err(_) => Some(metrics),
5276    }
5277}
5278
5279/// Spread health probes so a fleet-wide restart does not converge them.
5280///
5281/// The delay is derived from the module id and probe index rather than a random
5282/// source, so it is deterministic per module: a module keeps its own offset
5283/// across daemon restarts instead of re-rolling into a collision.
5284fn jittered_health_delay(module_id: &str, probe_index: u64, cadence: Duration) -> Duration {
5285    if cadence.is_zero() {
5286        return Duration::ZERO;
5287    }
5288    let cadence_ms = cadence.as_millis() as u64;
5289    // This early return is REDUNDANT, deliberately, and a mutation run will show
5290    // it surviving removal. Recording why here so the next person to notice does
5291    // not have to re-derive it:
5292    //
5293    // - It is unreachable in practice. `positive_millis` in daemon_config rejects
5294    //   a zero cadence and builds the Duration from whole milliseconds, so a
5295    //   sub-millisecond cadence cannot come from config.
5296    // - Even if reached it changes no answer. The `.max(1)` below makes the span
5297    //   1, and `hash % 1` is 0, so the fall-through returns `cadence` unchanged
5298    //   -- exactly what this returns.
5299    //
5300    // Kept as a guard against a future widening of the config parser (accepting
5301    // microseconds, say), which would make the sub-millisecond case reachable.
5302    // The `.max(1)` is the load-bearing half TODAY: remove it and the modulo
5303    // divides by zero. Remove this and nothing changes.
5304    if cadence_ms == 0 {
5305        return cadence;
5306    }
5307    // Note that this never returns less than one cadence, including for the FIRST
5308    // probe. So a freshly registered module reports health `unknown` for a full
5309    // cadence plus jitter -- 30-33s at the default -- no matter how quickly it is
5310    // ready to answer.
5311    //
5312    // That is a property of the supervisor's schedule, not of any module: an
5313    // operator watching a restart sees `unknown` and cannot tell it from a module
5314    // that is slow to warm. Measured on two unrelated modules, both flipping to
5315    // `ok` between 22s and 32s after restart.
5316    //
5317    // Left as-is because spreading the first probe is what keeps a fleet-wide
5318    // restart from firing fourteen simultaneous probes into a cold machine. The
5319    // alternative -- probe at t+0 and jitter only from the second onward -- trades
5320    // that thundering herd for a faster first reading.
5321    let jitter_span = (cadence_ms / 10).max(1);
5322    let hash = module_id.as_bytes().iter().fold(
5323        probe_index.wrapping_mul(0x9E37_79B9_7F4A_7C15),
5324        |acc, byte| {
5325            acc.wrapping_mul(1099511628211)
5326                .wrapping_add(u64::from(*byte))
5327        },
5328    );
5329    cadence + Duration::from_millis(hash % jitter_span)
5330}
5331
5332#[cfg(test)]
5333mod tests {
5334    use super::*;
5335
5336    #[test]
5337    fn readding_a_module_clears_its_rescan_removal_tombstone() {
5338        let handle = SupervisorHandle::new();
5339        let module_id = "readded-tombstone";
5340        handle.record_rescan_removal(module_id);
5341        assert!(handle.removal_tombstone_age_ms(module_id).is_some());
5342
5343        handle.apply_identity_configuration(&ModuleSpec {
5344            module_id: module_id.to_string(),
5345            program: PathBuf::from("/test/module"),
5346            args: Vec::new(),
5347            env: Vec::new(),
5348            reserved: false,
5349            reserved_prefixes: Vec::new(),
5350            protocol: ModuleProtocol::Subc,
5351            overlap: Default::default(),
5352        });
5353
5354        assert!(
5355            handle.removal_tombstone_age_ms(module_id).is_none(),
5356            "a re-added module must not retain a stale removal tombstone"
5357        );
5358    }
5359
5360    /// What one module's owner looked like from the control plane at the
5361    /// instant after its first process was spawned.
5362    #[derive(Debug, PartialEq, Eq)]
5363    struct OwnerInSpawnWindow {
5364        module_id: String,
5365        configured: bool,
5366        on_roster: bool,
5367        admission_refusal: Option<&'static str>,
5368    }
5369
5370    /// A supervised module's process can connect, register, sync its scopes
5371    /// and describe them as soon as it is spawned, which is BEFORE the
5372    /// supervisor puts the module on the roster. In that window the owner must
5373    /// already read as configured, so a scoped `route.open` against it is
5374    /// refused as retryable `scope_not_synced` and not as terminal
5375    /// `scope_not_live` ("will never sync").
5376    ///
5377    /// The hook runs in exactly that window on every path that takes on a new
5378    /// module, so no race with a real child is needed: `on_roster: false`
5379    /// proves each observation was taken before the roster insert.
5380    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5381    async fn a_new_module_is_configured_before_its_first_process_can_register() {
5382        use crate::scopes::ScopeTable;
5383        use subc_protocol::{error_codes, scope::ScopeSelector, Principal};
5384
5385        let dir = cortexkit_test_support::ScratchDir::new("configured-before-spawn");
5386        let stub = |module_id: &str, program: PathBuf| ModuleSpec {
5387            module_id: module_id.to_string(),
5388            program,
5389            args: Vec::new(),
5390            env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
5391                .into_iter()
5392                .map(|key| (key.to_string(), dir.path().display().to_string()))
5393                .chain([("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string())])
5394                .collect(),
5395            reserved: false,
5396            reserved_prefixes: Vec::new(),
5397            protocol: ModuleProtocol::Subc,
5398            overlap: Default::default(),
5399        };
5400        let live = super::terminal_history_tests::fake_aft_stub_path();
5401        let missing = dir.path().join("definitely-missing-module");
5402
5403        let handle = SupervisorHandle::new();
5404        let mut supervisor =
5405            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5406                .with_handle(handle.clone());
5407        let observed = Arc::new(Mutex::new(Vec::<OwnerInSpawnWindow>::new()));
5408        let hook_handle = handle.clone();
5409        let hook_observed = Arc::clone(&observed);
5410        supervisor.test_after_first_spawn = AfterFirstSpawnHook(Some(Arc::new(move |module_id| {
5411            // Exactly what the control plane computes for a scoped route.open
5412            // naming this module as the owner of a scope it has not synced.
5413            let configured = hook_handle.is_configured(module_id);
5414            let selector = ScopeSelector {
5415                owner: Principal::Reserved {
5416                    module_id: module_id.to_string(),
5417                },
5418                scope_ref: "s".to_string(),
5419                scope_epoch: Some(1),
5420            };
5421            let carrier = Principal::Reserved {
5422                module_id: "carrier".to_string(),
5423            };
5424            let admission_refusal = match ScopeTable::new(Vec::<String>::new())
5425                .admit(&carrier, module_id, &selector, configured)
5426            {
5427                Ok(_) => None,
5428                Err(refusal) => Some(refusal.code),
5429            };
5430            hook_observed.lock().unwrap().push(OwnerInSpawnWindow {
5431                module_id: module_id.to_string(),
5432                configured,
5433                on_roster: hook_handle.get(module_id).is_some(),
5434                admission_refusal,
5435            });
5436        })));
5437
5438        let plain = supervisor.spawn(stub("plain", live.clone())).unwrap();
5439        let configured = supervisor
5440            .supervise_configured(stub("configured", live.clone()), true)
5441            .unwrap();
5442        let with_health = supervisor
5443            .supervise_configured_with_health(
5444                stub("with-health", live.clone()),
5445                true,
5446                HealthConfig::default(),
5447                None,
5448                RestartPolicy::default(),
5449            )
5450            .unwrap();
5451        // The failed-spawn path still puts the module on the roster (as
5452        // failed), so it is configured throughout.
5453        let failed = supervisor
5454            .supervise_configured_with_health(
5455                stub("failed-spawn", missing.clone()),
5456                true,
5457                HealthConfig::default(),
5458                None,
5459                RestartPolicy::default(),
5460            )
5461            .unwrap();
5462        // A failed plain `spawn` puts nothing on the roster, so its mark is
5463        // taken back once the spawn has failed.
5464        assert!(supervisor.spawn(stub("spawn-error", missing)).is_err());
5465
5466        let expected = [
5467            "plain",
5468            "configured",
5469            "with-health",
5470            "failed-spawn",
5471            "spawn-error",
5472        ]
5473        .into_iter()
5474        .map(|module_id| OwnerInSpawnWindow {
5475            module_id: module_id.to_string(),
5476            configured: true,
5477            on_roster: false,
5478            admission_refusal: Some(error_codes::SCOPE_NOT_SYNCED),
5479        })
5480        .collect::<Vec<_>>();
5481        assert_eq!(*observed.lock().unwrap(), expected);
5482
5483        for module_id in ["plain", "configured", "with-health", "failed-spawn"] {
5484            assert!(
5485                handle.get(module_id).is_some(),
5486                "{module_id} is on the roster"
5487            );
5488            assert!(
5489                handle.is_configured(module_id),
5490                "{module_id} stays configured"
5491            );
5492        }
5493        assert!(handle.get("spawn-error").is_none());
5494        assert!(
5495            !handle.is_configured("spawn-error"),
5496            "a plain spawn that failed must not leave its module marked configured"
5497        );
5498
5499        // Leaving the roster clears the mark with it.
5500        handle.retire("failed-spawn");
5501        assert!(!handle.is_configured("failed-spawn"));
5502
5503        for module in [plain, configured, with_health] {
5504            module.stop().await.unwrap();
5505        }
5506        drop(failed);
5507    }
5508
5509    fn stale_process_snapshot(state: ModuleState, enabled: bool) -> SharedSnapshot {
5510        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(state, enabled)));
5511        update_snapshot(&snapshot, Some("stale-process-facts"), |snapshot| {
5512            snapshot.process_alive = true;
5513            snapshot.pid = Some(41);
5514            snapshot.spawned_at_ms = Some(42);
5515            snapshot.spawned_from = Some(PathBuf::from("/spawned/module"));
5516            snapshot.spawned_file_identity = Some(SpawnedFileIdentity {
5517                device: 43,
5518                inode: 44,
5519                #[cfg(windows)]
5520                windows_image: None,
5521            });
5522        })
5523        .unwrap();
5524        snapshot
5525    }
5526
5527    fn assert_snapshot_process_facts_cleared(snapshot: &SharedSnapshot) {
5528        let snapshot = lock_snapshot(snapshot).unwrap();
5529        assert!(!snapshot.process_alive);
5530        assert_eq!(snapshot.pid, None);
5531        assert_eq!(snapshot.spawned_at_ms, None);
5532        assert_eq!(snapshot.spawned_from, None);
5533        assert_eq!(snapshot.spawned_file_identity, None);
5534    }
5535
5536    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5537    async fn failed_enable_spawn_clears_preexisting_current_process_facts() {
5538        let supervisor =
5539            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5540        let mut runtime = supervisor.runtime_config();
5541        runtime.test_seed_stale_facts_before_enable_spawn = true;
5542        let snapshot = stale_process_snapshot(ModuleState::Disabled, false);
5543        let mut child = None;
5544        let spec = ModuleSpec {
5545            module_id: "failed-enable-clears-facts".to_string(),
5546            program: PathBuf::from("/definitely/missing/failed-enable-module"),
5547            args: Vec::new(),
5548            env: Vec::new(),
5549            reserved: false,
5550            reserved_prefixes: Vec::new(),
5551            protocol: ModuleProtocol::Subc,
5552            overlap: Default::default(),
5553        };
5554
5555        let result = set_child_enabled(
5556            &spec,
5557            &runtime,
5558            &supervisor.registry,
5559            &supervisor.process_liveness,
5560            &snapshot,
5561            &mut child,
5562            true,
5563        )
5564        .await;
5565
5566        assert!(matches!(result, Err(SuperviseError::Spawn { .. })));
5567        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
5568        assert_snapshot_process_facts_cleared(&snapshot);
5569    }
5570
5571    #[tokio::test]
5572    async fn start_revives_stranded_restarting_but_not_pending_backoff() {
5573        let supervisor =
5574            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5575        let runtime = supervisor.runtime_config();
5576        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(
5577            ModuleState::Restarting,
5578            true,
5579        )));
5580        let spec = ModuleSpec {
5581            module_id: "start-stranded-restarting".to_string(),
5582            program: super::terminal_history_tests::fake_aft_stub_path(),
5583            args: Vec::new(),
5584            env: vec![("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string())],
5585            reserved: false,
5586            reserved_prefixes: Vec::new(),
5587            protocol: ModuleProtocol::None,
5588            overlap: Default::default(),
5589        };
5590        let mut child = None;
5591        lock_snapshot(&snapshot).unwrap().respawn_pending = true;
5592        assert!(!super::set_child_enabled(
5593            &spec,
5594            &runtime,
5595            &Registry::default(),
5596            &supervisor.process_liveness,
5597            &snapshot,
5598            &mut child,
5599            true
5600        )
5601        .await
5602        .unwrap());
5603        assert!(child.is_none());
5604        lock_snapshot(&snapshot).unwrap().respawn_pending = false;
5605        assert!(super::set_child_enabled(
5606            &spec,
5607            &runtime,
5608            &Registry::default(),
5609            &supervisor.process_liveness,
5610            &snapshot,
5611            &mut child,
5612            true
5613        )
5614        .await
5615        .unwrap());
5616        assert_eq!(
5617            lock_snapshot(&snapshot).unwrap().state,
5618            ModuleState::Running
5619        );
5620        let mut child = child.unwrap();
5621        child.start_kill().unwrap();
5622        child.wait().await.unwrap();
5623    }
5624
5625    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5626    async fn failed_reload_spawn_clears_current_process_facts() {
5627        let supervisor =
5628            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5629        let mut runtime = supervisor.runtime_config();
5630        runtime.restart_policy = RestartPolicy::new(0, Duration::ZERO);
5631        let snapshot = stale_process_snapshot(ModuleState::Running, true);
5632        let mut child = None;
5633        let spec = ModuleSpec {
5634            module_id: "failed-reload-clears-facts".to_string(),
5635            program: PathBuf::from("/unused/failed-reload-module"),
5636            args: Vec::new(),
5637            env: Vec::new(),
5638            reserved: false,
5639            reserved_prefixes: Vec::new(),
5640            protocol: ModuleProtocol::Subc,
5641            overlap: Default::default(),
5642        };
5643
5644        let result = handle_reload_spawn_failure(
5645            &spec,
5646            &runtime,
5647            &supervisor.process_liveness,
5648            &snapshot,
5649            &mut child,
5650            "forced reload spawn failure".to_string(),
5651        )
5652        .await;
5653
5654        assert!(matches!(result, Err(SuperviseError::ReloadFailed { .. })));
5655        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
5656        assert_snapshot_process_facts_cleared(&snapshot);
5657    }
5658
5659    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5660    async fn dropping_a_module_with_an_active_monitor_clears_current_process_facts() {
5661        let supervisor =
5662            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5663        let snapshot = stale_process_snapshot(ModuleState::Running, true);
5664        let module = supervisor.supervised_module(
5665            ModuleSpec {
5666                module_id: "drop-clears-facts".to_string(),
5667                program: PathBuf::from("/unused/drop-module"),
5668                args: Vec::new(),
5669                env: Vec::new(),
5670                reserved: false,
5671                reserved_prefixes: Vec::new(),
5672                protocol: ModuleProtocol::Subc,
5673                overlap: Default::default(),
5674            },
5675            supervisor.runtime_config(),
5676            Arc::clone(&snapshot),
5677            None,
5678        );
5679        assert!(!module
5680            .inner
5681            .monitor
5682            .lock()
5683            .unwrap()
5684            .as_ref()
5685            .unwrap()
5686            .is_finished());
5687
5688        drop(module);
5689
5690        assert_eq!(
5691            lock_snapshot(&snapshot).unwrap().state,
5692            ModuleState::Stopped
5693        );
5694        assert_snapshot_process_facts_cleared(&snapshot);
5695    }
5696
5697    #[cfg(unix)]
5698    #[tokio::test]
5699    async fn rescan_preserves_running_protocol_until_respawn() {
5700        let dir = cortexkit_test_support::ScratchDir::new("rescan-protocol");
5701        let initial = ModuleSpec {
5702            module_id: "rescan-protocol".into(),
5703            program: PathBuf::from("/bin/sleep"),
5704            args: vec!["60".into()],
5705            env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
5706                .into_iter()
5707                .map(|key| (key.into(), dir.path().to_string_lossy().into_owned()))
5708                .collect(),
5709            reserved: false,
5710            reserved_prefixes: vec![],
5711            protocol: ModuleProtocol::None,
5712            overlap: Default::default(),
5713        };
5714        let supervisor =
5715            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5716        let module = supervisor.spawn(initial.clone()).unwrap();
5717        assert!(module.status().unwrap().live);
5718        let mut next = initial;
5719        next.protocol = ModuleProtocol::Subc;
5720        module
5721            .update_configuration(next.clone(), HealthConfig::default(), None)
5722            .await
5723            .unwrap();
5724        assert!(
5725            module.status().unwrap().live,
5726            "rescan must not require HELLO from the old non-wire process"
5727        );
5728        let runtime = supervisor.runtime_config();
5729        let action = on_child_exit(
5730            &next,
5731            RestartPolicy::default(),
5732            &supervisor.registry,
5733            &module.inner.snapshot,
5734            &runtime.terminal_ring,
5735            &runtime.spawn_events,
5736            &runtime.child_roster,
5737            ExitReport {
5738                kind: ExitKind::Clean,
5739                code: Some(0),
5740                signal: None,
5741                at_ms: unix_ms_now(),
5742            },
5743        )
5744        .await;
5745        assert!(
5746            matches!(action, NextAction::Restart { .. }),
5747            "the old non-wire process's clean exit must restart"
5748        );
5749        module.drain().await.unwrap();
5750    }
5751
5752    #[cfg(unix)]
5753    #[tokio::test]
5754    async fn orphan_identity_matches_path_names_and_shebang_interpreters() {
5755        use std::os::unix::fs::PermissionsExt;
5756        let dir = cortexkit_test_support::ScratchDir::new("spawn-image-identity");
5757        let script = dir.join("module.sh");
5758        std::fs::write(&script, "#!/bin/sh\nwhile :; do sleep 0.1; done\n").unwrap();
5759        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700)).unwrap();
5760        let record_path = dir.join("live-children.json");
5761        let supervisor =
5762            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5763                .with_live_children_record(&record_path);
5764        for (program, args) in [
5765            (PathBuf::from("sleep"), vec!["60".into()]),
5766            (script, vec![]),
5767        ] {
5768            let spec = ModuleSpec {
5769                module_id: "image-identity".into(),
5770                program: program.clone(),
5771                args,
5772                env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
5773                    .into_iter()
5774                    .map(|key| (key.into(), dir.to_string_lossy().into_owned()))
5775                    .collect(),
5776                reserved: false,
5777                reserved_prefixes: vec![],
5778                protocol: ModuleProtocol::None,
5779                overlap: Default::default(),
5780            };
5781            let module = supervisor.spawn(spec).unwrap();
5782            #[cfg(target_os = "macos")]
5783            {
5784                // SETEXEC confirmation is asynchronous; the orphan record must
5785                // identify the final image, never the intermediate trampoline.
5786                let deadline = Instant::now() + Duration::from_secs(5);
5787                while crate::live_children::read_record(&record_path)
5788                    .unwrap()
5789                    .iter()
5790                    .all(|entry| entry.executable.is_none())
5791                {
5792                    assert!(Instant::now() < deadline, "module image was not confirmed");
5793                    tokio::time::sleep(Duration::from_millis(5)).await;
5794                }
5795            }
5796            let entry = crate::live_children::read_record(&record_path)
5797                .unwrap()
5798                .pop()
5799                .unwrap();
5800            let observed = subc_os::Process::open(entry.pid)
5801                .unwrap()
5802                .unwrap()
5803                .observe()
5804                .unwrap();
5805            let verdict = crate::live_children::identity_verdict(&entry, entry.pid, &observed);
5806            module.drain().await.unwrap();
5807            assert_eq!(
5808                verdict,
5809                crate::live_children::IdentityVerdict::Matches,
5810                "program {program:?}: recorded {entry:?}, observed {observed:?}"
5811            );
5812        }
5813    }
5814
5815    #[cfg(unix)]
5816    fn http_fixture(
5817        dir: &std::path::Path,
5818        url: &str,
5819        threshold: u32,
5820    ) -> crate::daemon_config::ConfiguredModule {
5821        let path = dir.join("subc.jsonc");
5822        std::fs::write(&path, serde_json::json!({"version": 1, "modules": {"http-process": {
5823            "program": "/bin/sleep", "args": ["60"], "protocol": "none",
5824            "env": {"XDG_DATA_HOME":dir,"XDG_RUNTIME_DIR":dir,"XDG_CONFIG_HOME":dir},
5825            "health": {"http":url,"cadence_ms":20,"deadline_ms":200,"failure_threshold":threshold},
5826            "restart": {"backoff_ms":1,"max_backoff_ms":1}, "drain_timeout_ms":10
5827        }}}).to_string()).unwrap();
5828        crate::daemon_config::load(&path)
5829            .unwrap()
5830            .unwrap()
5831            .modules
5832            .pop()
5833            .unwrap()
5834    }
5835
5836    #[cfg(unix)]
5837    async fn wait_http_health(module: &SupervisedModule, status: SupervisorHealthStatus) {
5838        timeout(Duration::from_secs(5), async {
5839            loop {
5840                if module.status().unwrap().health.status == status {
5841                    break;
5842                }
5843                sleep(Duration::from_millis(5)).await;
5844            }
5845        })
5846        .await
5847        .unwrap_or_else(|_| {
5848            panic!(
5849                "expected {status:?}, got {:?}",
5850                module.status().unwrap().health
5851            )
5852        });
5853    }
5854
5855    #[cfg(unix)]
5856    #[tokio::test]
5857    async fn http_health_status_flips_ok_failing_ok() {
5858        use tokio::io::{AsyncReadExt, AsyncWriteExt};
5859        let dir = cortexkit_test_support::ScratchDir::new("http-status-flips");
5860        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5861        let status = Arc::new(std::sync::atomic::AtomicU16::new(200));
5862        let serving_status = status.clone();
5863        let url = format!("http://{}/healthz", listener.local_addr().unwrap());
5864        let server = tokio::spawn(async move {
5865            loop {
5866                let (mut stream, _) = listener.accept().await.unwrap();
5867                let mut request = [0u8; 2048];
5868                let count = stream.read(&mut request).await.unwrap();
5869                assert!(count > 0, "a probe must send an HTTP request");
5870                let code = serving_status.load(std::sync::atomic::Ordering::SeqCst);
5871                let body = if code == 200 {
5872                    "ready"
5873                } else {
5874                    "{\"status\":\"unavailable\",\"error\":\"scratch failure\"}"
5875                };
5876                let response = format!("HTTP/1.1 {code} scratch\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
5877                let _ = stream.write_all(response.as_bytes()).await;
5878            }
5879        });
5880        let configured = http_fixture(&dir, &url, 1000);
5881        let module =
5882            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5883                .supervise_configured_with_health(
5884                    configured.module_spec(),
5885                    true,
5886                    configured.health,
5887                    configured.drain_timeout_ms,
5888                    configured.restart,
5889                )
5890                .unwrap();
5891        wait_http_health(&module, SupervisorHealthStatus::Ok).await;
5892        status.store(503, std::sync::atomic::Ordering::SeqCst);
5893        wait_http_health(&module, SupervisorHealthStatus::Failing).await;
5894        assert!(module
5895            .status()
5896            .unwrap()
5897            .health
5898            .detail
5899            .unwrap()
5900            .contains("scratch failure"));
5901        status.store(200, std::sync::atomic::Ordering::SeqCst);
5902        wait_http_health(&module, SupervisorHealthStatus::Ok).await;
5903        assert_eq!(module.status().unwrap().health.consecutive_failures, 0);
5904        let before = module.status().unwrap();
5905        let (spec, mut health) = module.configuration().unwrap();
5906        health.http = None;
5907        module
5908            .update_configuration(spec.clone(), health.clone(), Some(10))
5909            .await
5910            .unwrap();
5911        wait_http_health(&module, SupervisorHealthStatus::Unknown).await;
5912        health.http = Some(url);
5913        module
5914            .update_configuration(spec, health, Some(10))
5915            .await
5916            .unwrap();
5917        wait_http_health(&module, SupervisorHealthStatus::Ok).await;
5918        assert_eq!(
5919            module.status().unwrap().pid,
5920            before.pid,
5921            "changing a probe must apply live, not restart its process"
5922        );
5923        let (spec, mut health) = module.configuration().unwrap();
5924        health.failure_threshold = 2;
5925        module
5926            .update_configuration(spec, health, Some(10))
5927            .await
5928            .unwrap();
5929        status.store(503, std::sync::atomic::Ordering::SeqCst);
5930        timeout(Duration::from_secs(5), async {
5931            while module.status().unwrap().spawn_generation == before.spawn_generation {
5932                sleep(Duration::from_millis(5)).await;
5933            }
5934        })
5935        .await
5936        .expect("sustained HTTP 503 responses must trigger the health restart policy");
5937        module.drain().await.unwrap();
5938        server.abort();
5939    }
5940
5941    #[cfg(unix)]
5942    #[tokio::test]
5943    async fn http_health_no_listener_restarts_after_consecutive_failures() {
5944        let dir = cortexkit_test_support::ScratchDir::new("http-refused");
5945        let unused = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5946        let url = format!("http://{}/healthz", unused.local_addr().unwrap());
5947        drop(unused);
5948        let configured = http_fixture(&dir, &url, 2);
5949        let module =
5950            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5951                .supervise_configured_with_health(
5952                    configured.module_spec(),
5953                    true,
5954                    configured.health,
5955                    configured.drain_timeout_ms,
5956                    configured.restart,
5957                )
5958                .unwrap();
5959        let before = module.status().unwrap().spawn_generation;
5960        timeout(Duration::from_secs(5), async {
5961            loop {
5962                let status = module.status().unwrap();
5963                if status.spawn_generation > before {
5964                    assert!(status.lifetime_restarts > 0);
5965                    break;
5966                }
5967                sleep(Duration::from_millis(5)).await;
5968            }
5969        })
5970        .await
5971        .expect("sustained HTTP refusal must trigger the health restart policy");
5972        module.drain().await.unwrap();
5973    }
5974
5975    #[cfg(unix)]
5976    #[tokio::test]
5977    async fn http_health_timeout_honours_deadline() {
5978        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5979        let url = format!("http://{}/healthz", listener.local_addr().unwrap());
5980        let server = tokio::spawn(async move {
5981            let _held = listener.accept().await.unwrap();
5982            std::future::pending::<()>().await;
5983        });
5984        let error = timeout(
5985            Duration::from_secs(1),
5986            probe_http_health(&url, Duration::from_millis(10)),
5987        )
5988        .await
5989        .expect("the probe must enforce its own deadline")
5990        .unwrap_err();
5991        server.abort();
5992        assert!(error.to_string().contains("timed out"));
5993    }
5994
5995    #[cfg(unix)]
5996    #[tokio::test]
5997    async fn http_health_localhost_reaches_an_ipv6_only_listener() {
5998        use tokio::io::{AsyncReadExt, AsyncWriteExt};
5999        let listener = tokio::net::TcpListener::bind("[::1]:0").await.unwrap();
6000        let url = format!(
6001            "http://localhost:{}/healthz",
6002            listener.local_addr().unwrap().port()
6003        );
6004        let server = tokio::spawn(async move {
6005            let (mut stream, _) = listener.accept().await.unwrap();
6006            let mut request = [0u8; 2048];
6007            assert!(stream.read(&mut request).await.unwrap() > 0);
6008            stream
6009                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
6010                .await
6011                .unwrap();
6012        });
6013        // The deadline only bounds a hang. A probe that never tried the IPv6
6014        // address would be refused on 127.0.0.1 and fail at once, so a longer
6015        // deadline does not weaken the assertion; one second timed out under a
6016        // loaded parallel test run.
6017        assert_eq!(
6018            probe_http_health(&url, Duration::from_secs(10))
6019                .await
6020                .unwrap()
6021                .status,
6022            HealthStatus::Ok
6023        );
6024        server.await.unwrap();
6025    }
6026
6027    #[cfg(unix)]
6028    #[tokio::test]
6029    async fn http_health_timeout_keeps_partial_status_and_body() {
6030        use tokio::io::{AsyncReadExt, AsyncWriteExt};
6031        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6032        let url = format!("http://{}/healthz", listener.local_addr().unwrap());
6033        let server = tokio::spawn(async move {
6034            let (mut stream, _) = listener.accept().await.unwrap();
6035            let mut request = [0u8; 2048];
6036            assert!(stream.read(&mut request).await.unwrap() > 0);
6037            stream
6038                .write_all(
6039                    b"HTTP/1.1 503 Unavailable\r\nContent-Length: 100\r\n\r\npartial diagnostic",
6040                )
6041                .await
6042                .unwrap();
6043            std::future::pending::<()>().await;
6044        });
6045        let error = probe_http_health(&url, Duration::from_secs(1))
6046            .await
6047            .unwrap_err()
6048            .to_string();
6049        server.abort();
6050        assert!(
6051            error.contains("timed out")
6052                && error.contains("503 Unavailable")
6053                && error.contains("partial diagnostic"),
6054            "{error}"
6055        );
6056    }
6057
6058    #[cfg(unix)]
6059    #[tokio::test]
6060    async fn http_health_diagnostic_body_is_bounded_to_200_bytes() {
6061        use tokio::io::{AsyncReadExt, AsyncWriteExt};
6062        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6063        let url = format!("http://{}/healthz", listener.local_addr().unwrap());
6064        let server = tokio::spawn(async move {
6065            let (mut stream, _) = listener.accept().await.unwrap();
6066            let mut request = [0u8; 2048];
6067            assert!(stream.read(&mut request).await.unwrap() > 0);
6068            let body = format!("{}not-in-diagnostic", "x".repeat(400));
6069            let response = format!(
6070                "HTTP/1.1 503 Unavailable\r\nContent-Length: {}\r\n\r\n{body}",
6071                body.len()
6072            );
6073            stream.write_all(response.as_bytes()).await.unwrap();
6074        });
6075        let error = probe_http_health(&url, Duration::from_secs(1))
6076            .await
6077            .unwrap_err()
6078            .to_string();
6079        server.await.unwrap();
6080        assert!(error.ends_with(&"x".repeat(200)), "{error}");
6081        assert_eq!(error.split(": ").last().unwrap().len(), 200);
6082        assert!(!error.contains("not-in-diagnostic"));
6083    }
6084
6085    #[cfg(unix)]
6086    #[tokio::test]
6087    async fn http_health_real_nats_server_monitoring() {
6088        if std::process::Command::new("nats-server")
6089            .arg("--version")
6090            .env("XDG_DATA_HOME", std::env::temp_dir())
6091            .env("XDG_RUNTIME_DIR", std::env::temp_dir())
6092            .env("XDG_CONFIG_HOME", std::env::temp_dir())
6093            .output()
6094            .is_err()
6095        {
6096            eprintln!(
6097                "skipped: http_health_real_nats_server_monitoring (nats-server is not on PATH)"
6098            );
6099            return;
6100        }
6101        let dir = cortexkit_test_support::ScratchDir::new("nats-http-monitoring");
6102        let monitor = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6103        let port = monitor.local_addr().unwrap().port();
6104        let client = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6105        let client_port = client.local_addr().unwrap().port();
6106        let config = dir.join("server.conf");
6107        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();
6108        let mut configured = http_fixture(&dir, &format!("http://127.0.0.1:{port}/healthz"), 1000);
6109        configured.program = PathBuf::from("nats-server");
6110        configured.args = vec!["-c".into(), config.to_string_lossy().into_owned()];
6111        drop(monitor);
6112        drop(client);
6113        let module =
6114            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
6115                .supervise_configured_with_health(
6116                    configured.module_spec(),
6117                    true,
6118                    configured.health,
6119                    configured.drain_timeout_ms,
6120                    configured.restart,
6121                )
6122                .unwrap();
6123        wait_http_health(&module, SupervisorHealthStatus::Ok).await;
6124        module.drain().await.unwrap();
6125    }
6126
6127    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
6128    async fn configuration_update_does_not_replace_captured_running_process_facts() {
6129        let supervisor =
6130            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
6131        let snapshot = stale_process_snapshot(ModuleState::Running, true);
6132        let initial = ModuleSpec {
6133            module_id: "rescan-preserves-spawn-facts".to_string(),
6134            program: PathBuf::from("/spawned/module"),
6135            args: Vec::new(),
6136            env: Vec::new(),
6137            reserved: false,
6138            reserved_prefixes: Vec::new(),
6139            protocol: ModuleProtocol::Subc,
6140            overlap: Default::default(),
6141        };
6142        let module = supervisor.supervised_module(
6143            initial.clone(),
6144            supervisor.runtime_config(),
6145            snapshot,
6146            None,
6147        );
6148        let before = module.status().unwrap();
6149        let mut replacement = initial;
6150        replacement.program = PathBuf::from("/rescanned/replacement-module");
6151
6152        module
6153            .update_configuration(replacement, HealthConfig::default(), None)
6154            .await
6155            .unwrap();
6156
6157        let after = module.status().unwrap();
6158        assert_eq!(after.pid, before.pid);
6159        assert_eq!(after.spawned_at_ms, before.spawned_at_ms);
6160        assert_eq!(after.spawned_from, before.spawned_from);
6161        drop(module);
6162    }
6163}
6164
6165fn unix_ms_now() -> u64 {
6166    SystemTime::now()
6167        .duration_since(UNIX_EPOCH)
6168        .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
6169        .unwrap_or(0)
6170}
6171
6172async fn supervise_loop(
6173    mut spec: ModuleSpec,
6174    mut runtime: SupervisorRuntimeConfig,
6175    registry: Arc<Registry>,
6176    process_liveness: Arc<SupervisorProcessLiveness>,
6177    snapshot: SharedSnapshot,
6178    mut child: Option<SupervisedChild>,
6179    mut commands: mpsc::Receiver<SupervisorCommand>,
6180) {
6181    let mut health_probe = HealthProbeRuntime::default();
6182    // Registry writes (including embedded callers) notify this module only.
6183    // Subscribe before the first refresh; watch retains changes that arrive
6184    // while commands or probes are running. No polling fallback is needed.
6185    let mut registration_changes = match registry.subscribe_module_changes(&spec.module_id) {
6186        Ok(changes) => Some(changes),
6187        Err(err) => {
6188            // A poisoned registry cannot accept further writes, so it cannot
6189            // recover via a timer. Keep exit and command handling alive.
6190            warn!(module_id = %spec.module_id, error = %err, "health prober could not subscribe to registry");
6191            None
6192        }
6193    };
6194    // All restart backoffs run here, including health and operator requests.
6195    // While one is pending the loop serves commands, so disable or drain can
6196    // cancel the replacement without spawning a process just to stop it.
6197    let mut pending_respawn: Option<PendingRespawn> = None;
6198    // Commands a swap handed back to run next (see `swap::SwapEnd`). Served
6199    // before anything else so a stop that interrupted a swap runs at once.
6200    let mut requeued: VecDeque<SupervisorCommand> = VecDeque::new();
6201    #[cfg(target_os = "linux")]
6202    let mut previous_instance_recheck = tokio::time::interval_at(
6203        Instant::now() + Duration::from_secs(5),
6204        Duration::from_secs(5),
6205    );
6206    #[cfg(target_os = "linux")]
6207    previous_instance_recheck.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
6208    loop {
6209        #[cfg(test)]
6210        {
6211            lock_snapshot(&snapshot).unwrap().actor_select = None;
6212        }
6213        #[cfg(target_os = "macos")]
6214        if let Some(active) = child.as_mut() {
6215            active.confirm_privacy_exec().await;
6216        }
6217        if let Some(scheduled) = runtime
6218            .scheduled_respawn
6219            .lock()
6220            .unwrap_or_else(|p| p.into_inner())
6221            .take()
6222        {
6223            pending_respawn = Some(scheduled);
6224        }
6225        if pending_respawn.is_some() && (child.is_some() || !respawn_still_pending(&snapshot)) {
6226            pending_respawn = None;
6227            cancel_deferred_reload(
6228                &runtime,
6229                &spec.module_id,
6230                "respawn cancelled by a supervisor command",
6231            );
6232        }
6233        if child.is_none()
6234            && pending_respawn.is_none()
6235            && lock_snapshot(&snapshot).is_ok_and(|state| state.waiting_for_previous_pid.is_none())
6236        {
6237            cancel_deferred_reload(
6238                &runtime,
6239                &spec.module_id,
6240                "respawn cancelled before a replacement was spawned",
6241            );
6242            let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6243                state.respawn_pending = false;
6244                state.coalesced_restart_pending = false;
6245                if matches!(
6246                    state.state,
6247                    ModuleState::Restarting
6248                        | ModuleState::Starting
6249                        | ModuleState::Draining
6250                        | ModuleState::Unresponsive
6251                ) {
6252                    error!(module_id = %spec.module_id, state = ?state.state,
6253                        "supervision operation ended without a child or pending respawn; marking failed so start can retry");
6254                    state.state = ModuleState::Failed;
6255                    clear_current_process_facts(state);
6256                }
6257            });
6258        }
6259        if let Some(command) = requeued.pop_front() {
6260            if !handle_supervisor_command(
6261                command,
6262                &mut spec,
6263                &mut runtime,
6264                &registry,
6265                &process_liveness,
6266                &snapshot,
6267                &mut child,
6268                &mut commands,
6269                &mut requeued,
6270            )
6271            .await
6272            {
6273                return;
6274            }
6275            if child.is_some() || !respawn_still_pending(&snapshot) {
6276                pending_respawn = None;
6277                let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6278                    state.respawn_pending = false
6279                });
6280            }
6281            continue;
6282        }
6283        if child.is_some() {
6284            #[cfg(test)]
6285            {
6286                lock_snapshot(&snapshot).unwrap().actor_turns += 1;
6287            }
6288            health_probe.refresh_registration(&spec, &runtime, &registry, &snapshot);
6289            let wake_after = health_probe.wake_after();
6290            let probe_sleep = async move {
6291                match wake_after {
6292                    Some(delay) => sleep(delay).await,
6293                    None => std::future::pending().await,
6294                }
6295            };
6296            tokio::pin!(probe_sleep);
6297            // A `protocol: "none"` child (a plain process that never registers
6298            // over the subc wire, such as nats-server) with no HTTP health
6299            // check has nothing to probe, so it waits only for its exit or a
6300            // supervisor command. A rescan that adds an HTTP check is a command.
6301            let wire_child = running_protocol(&spec, &snapshot) == ModuleProtocol::Subc;
6302            #[cfg(test)]
6303            {
6304                let registration_pending = wire_child
6305                    && registration_changes
6306                        .as_ref()
6307                        .is_some_and(|changes| changes.has_changed().unwrap());
6308                let mut state = lock_snapshot(&snapshot).unwrap();
6309                state.actor_select = (!registration_pending).then_some(ActorSelectCheckpoint {
6310                    generation: state.spawn_generation,
6311                    turn: state.actor_turns,
6312                    registered_connection: health_probe.registered_connection,
6313                    next_probe_at: health_probe.next_probe_at,
6314                    wake_after,
6315                });
6316            }
6317            let active_child = child.as_mut().expect("child checked above");
6318            tokio::select! {
6319                wait_result = active_child.wait() => {
6320                    #[cfg(test)]
6321                    { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6322                    // Every arm below that gives up on the CHILD must keep the
6323                    // supervision task itself alive (child = None, loop
6324                    // continues into command-serving mode). Returning here
6325                    // closes the command channel, which makes the module
6326                    // permanently unrestartable in-band: a clean child exit
6327                    // of an enabled module once wedged the fleet this way
6328                    // ('supervisor command channel is closed') and required a
6329                    // full daemon restart to recover.
6330                    let exit_report = match wait_result {
6331                        Ok(status) => classify_reaped_child_exit(&snapshot, active_child, &status),
6332                        Err(err) => {
6333                            active_child.drain_stderr(&spec.module_id).await;
6334                            fail_after_wait_error(
6335                                &spec.module_id,
6336                                &snapshot,
6337                                &runtime.terminal_ring,
6338                                &runtime.spawn_events,
6339                                format!("failed to wait for supervised module: {err}"),
6340                            );
6341                            untrack_if_registration_released(
6342                                &process_liveness,
6343                                &registry,
6344                                &spec.module_id,
6345                                &snapshot,
6346                            );
6347                            error!(module_id = %spec.module_id, error = %err, "failed to wait for supervised module");
6348                            child = None;
6349                            continue;
6350                        }
6351                    };
6352                    active_child.drain_stderr(&spec.module_id).await;
6353
6354                    let next = on_child_exit(
6355                        &spec,
6356                        runtime.restart_policy,
6357                        &registry,
6358                        &snapshot,
6359                        &runtime.terminal_ring,
6360                        &runtime.spawn_events,
6361                        &runtime.child_roster,
6362                        exit_report,
6363                    ).await;
6364                    runtime.start_requester = None;
6365                    runtime.pending_activity_restart = false;
6366                    // The exit is recorded, so a daemon shutdown may stop
6367                    // waiting for this child (see `SupervisedChild::wait`).
6368                    active_child.release_roster();
6369                    match next {
6370                        NextAction::Stop { registration_released } => {
6371                            if registration_released {
6372                                process_liveness.untrack_if_current(&spec.module_id, &snapshot);
6373                            }
6374                            child = None;
6375                        }
6376                        NextAction::Restart { schedule } => {
6377                            let delay = schedule.map_or(
6378                                runtime.restart_policy.delay_for_restart(0),
6379                                |schedule| schedule.delay,
6380                            );
6381                            if let Some(schedule) = schedule {
6382                                log_crash_respawn(&spec.module_id, schedule);
6383                            }
6384                            // The exited child is fully recorded at this point,
6385                            // so release it and count the backoff down in the
6386                            // command-serving branch below rather than sleeping
6387                            // here: commands cannot be received from inside this
6388                            // select arm, and an operator disable or drain that
6389                            // arrives during the backoff must cancel the pending
6390                            // respawn instead of waiting for it to spawn first.
6391                            child = None;
6392                            pending_respawn = Some(PendingRespawn { deadline: Instant::now() + delay, kind: RespawnKind::Spawn });
6393                             let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| state.respawn_pending = true);
6394                        }
6395                    }
6396                }
6397                command = commands.recv() => {
6398                    #[cfg(test)]
6399                    { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6400                    let Some(command) = command else {
6401                        return;
6402                    };
6403                    if !handle_supervisor_command(
6404                        command,
6405                        &mut spec,
6406                        &mut runtime,
6407                        &registry,
6408                        &process_liveness,
6409                        &snapshot,
6410                        &mut child,
6411                        &mut commands,
6412                        &mut requeued,
6413                    ).await {
6414                        return;
6415                    }
6416                }
6417                _ = async {
6418                    match registration_changes.as_mut() {
6419                        Some(changes) => { let _ = changes.changed().await; }
6420                        None => std::future::pending().await,
6421                    }
6422                }, if wire_child => {
6423                    #[cfg(test)]
6424                    { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6425                }
6426                _ = &mut probe_sleep => {
6427                    #[cfg(test)]
6428                    { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6429                    if health_probe.due() {
6430                        run_health_probe_cycle(
6431                            &spec,
6432                            &runtime,
6433                            &registry,
6434                            &process_liveness,
6435                            &snapshot,
6436                            &mut child,
6437                        ).await;
6438                        if child.is_none() {
6439                            runtime.start_requester = None;
6440                            runtime.pending_activity_restart = false;
6441                        }
6442                        if child.is_some() {
6443                            health_probe.schedule_next(&spec, runtime.health.cadence);
6444                        }
6445                    }
6446                }
6447            }
6448        } else if let Some(pending) = pending_respawn {
6449            tokio::select! {
6450                _ = sleep_until(pending.deadline) => {
6451                    pending_respawn = None;
6452                let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| state.respawn_pending = false);
6453                    // A command handled below while the backoff elapsed may
6454                    // have stopped the module; never respawn past an operator's
6455                    // disable or drain.
6456                    if !respawn_still_pending(&snapshot) {
6457                        continue;
6458                    }
6459                    // The daemon began shutting down during the backoff: the
6460                    // spawn would be refused anyway, and refusing it here
6461                    // leaves the module stopped instead of reporting a
6462                    // failed restart.
6463                    if runtime.child_roster.is_closed() {
6464                        let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6465                            state.state = ModuleState::Stopped;
6466                        });
6467                        debug!(module_id = %spec.module_id, "crash respawn cancelled by daemon shutdown");
6468                        continue;
6469                    }
6470                    if let Err(err) = release_dead_registration(
6471                        &registry,
6472                        runtime.forwarding.as_deref(),
6473                        &snapshot,
6474                        &spec.module_id,
6475                    ).await {
6476                        fail_snapshot(&snapshot, Some(&spec.module_id), None);
6477                        error!(module_id = %spec.module_id, error = %err, "registration did not release before restart");
6478                        continue;
6479                    }
6480
6481                    if matches!(pending.kind, RespawnKind::Reload) {
6482                        let reply = runtime.deferred_reload_reply.lock().unwrap_or_else(|p| p.into_inner()).take();
6483                        let result = finish_reload_child(&spec, &runtime, &registry, &process_liveness, &snapshot, &mut child).await;
6484                        if result.is_ok() && runtime.pending_activity_restart {
6485                            runtime.record_activity_transition("subc.module_restarted", &spec.module_id, runtime.start_requester.clone());
6486                        }
6487                        runtime.pending_activity_restart = false;
6488                        if let Some(reply) = reply { let _ = reply.send(result); }
6489                        continue;
6490                    }
6491                    process_liveness.track(spec.module_id.clone(), Arc::clone(&snapshot));
6492                    match spawn_and_mark_running(&spec, &runtime, &snapshot) {
6493                        Ok(next_child) => {
6494                            child = Some(next_child);
6495                            if runtime.pending_activity_restart {
6496                                runtime.record_activity_transition("subc.module_restarted", &spec.module_id, runtime.start_requester.clone());
6497                                runtime.pending_activity_restart = false;
6498                            }
6499                            debug!(module_id = %spec.module_id, "supervised module restarted after crash");
6500                        }
6501                        Err(err) => {
6502                            fail_snapshot(&snapshot, Some(&spec.module_id), None);
6503                            process_liveness.untrack_if_current(&spec.module_id, &snapshot);
6504                            error!(module_id = %spec.module_id, error = %err, "failed to restart supervised module");
6505                        }
6506                    }
6507                }
6508                command = commands.recv() => {
6509                    let Some(command) = command else {
6510                        return;
6511                    };
6512                    if !handle_supervisor_command(
6513                        command,
6514                        &mut spec,
6515                        &mut runtime,
6516                        &registry,
6517                        &process_liveness,
6518                        &snapshot,
6519                        &mut child,
6520                        &mut commands,
6521                        &mut requeued,
6522                    ).await {
6523                        return;
6524                    }
6525                    // Reconcile the pending respawn with what the command did:
6526                    // a start may already have spawned a fresh child,
6527                    // while a disable or drain moved the snapshot out of the
6528                    // state the respawn was counting down from.
6529                    if child.is_some() || !respawn_still_pending(&snapshot) {
6530                        pending_respawn = None;
6531                let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| state.respawn_pending = false);
6532                    }
6533                }
6534            }
6535        } else {
6536            #[cfg(target_os = "linux")]
6537            let command = if lock_snapshot(&snapshot)
6538                .is_ok_and(|state| state.waiting_for_previous_pid.is_some())
6539            {
6540                tokio::select! {
6541                    command = commands.recv() => command,
6542                    _ = previous_instance_recheck.tick() => {
6543                        if runtime.child_roster.previous_instance_pid(&spec.module_id).is_none() {
6544                            let enabled = lock_snapshot(&snapshot).is_ok_and(|state| state.enabled);
6545                            if enabled && !runtime.child_roster.is_closed() {
6546                                process_liveness.track(spec.module_id.clone(), Arc::clone(&snapshot));
6547                                match spawn_and_mark_running(&spec, &runtime, &snapshot) {
6548                                    Ok(next_child) => child = Some(next_child),
6549                                    Err(error) => {
6550                                        fail_snapshot(&snapshot, Some(&spec.module_id), None);
6551                                        error!(module_id = %spec.module_id, %error, "failed to start module after its previous instance exited");
6552                                    }
6553                                }
6554                            } else {
6555                                let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6556                                    state.waiting_for_previous_pid = None;
6557                                });
6558                            }
6559                        }
6560                        continue;
6561                    }
6562                }
6563            } else {
6564                commands.recv().await
6565            };
6566            #[cfg(not(target_os = "linux"))]
6567            let command = commands.recv().await;
6568            let Some(command) = command else {
6569                return;
6570            };
6571            if !handle_supervisor_command(
6572                command,
6573                &mut spec,
6574                &mut runtime,
6575                &registry,
6576                &process_liveness,
6577                &snapshot,
6578                &mut child,
6579                &mut commands,
6580                &mut requeued,
6581            )
6582            .await
6583            {
6584                return;
6585            }
6586        }
6587    }
6588}
6589
6590fn log_crash_respawn(module_id: &str, schedule: CrashRestartSchedule) {
6591    info!(
6592        module_id,
6593        restart_in_window = schedule.restart_in_window,
6594        delay_ms = schedule.delay.as_millis() as u64,
6595        "respawning after crash"
6596    );
6597}
6598
6599/// Whether the respawn a backoff was counting down to is still wanted. A
6600/// disable or drain handled while the backoff elapsed moves the snapshot out
6601/// of `Restarting`, and the operator's stop must win over the pending respawn,
6602/// so every sleep-then-spawn path re-validates against the live snapshot
6603/// instead of assuming the state it left behind still holds.
6604fn respawn_still_pending(snapshot: &SharedSnapshot) -> bool {
6605    matches!(
6606        lock_snapshot(snapshot),
6607        Ok(state) if state.enabled && state.state == ModuleState::Restarting
6608    )
6609}
6610
6611enum NextAction {
6612    Stop {
6613        registration_released: bool,
6614    },
6615    Restart {
6616        schedule: Option<CrashRestartSchedule>,
6617    },
6618}
6619
6620#[allow(clippy::too_many_arguments)]
6621async fn handle_supervisor_command(
6622    command: SupervisorCommand,
6623    spec: &mut ModuleSpec,
6624    runtime: &mut SupervisorRuntimeConfig,
6625    registry: &Arc<Registry>,
6626    process_liveness: &SupervisorProcessLiveness,
6627    snapshot: &SharedSnapshot,
6628    child: &mut Option<SupervisedChild>,
6629    commands: &mut mpsc::Receiver<SupervisorCommand>,
6630    requeued: &mut VecDeque<SupervisorCommand>,
6631) -> bool {
6632    #[cfg(target_os = "linux")]
6633    let command = if let Some(pid) = runtime.child_roster.previous_instance_pid(&spec.module_id) {
6634        match command {
6635            SupervisorCommand::Restart { reply, .. }
6636            | SupervisorCommand::Reload { reply, .. }
6637            | SupervisorCommand::Swap { reply, .. } => {
6638                let _ = reply.send(Err(previous_instance_error(spec, pid)));
6639                return true;
6640            }
6641            SupervisorCommand::SetEnabled {
6642                enabled: true,
6643                reply,
6644                ..
6645            } => {
6646                let _ = reply.send(Err(previous_instance_error(spec, pid)));
6647                return true;
6648            }
6649            other => other,
6650        }
6651    } else {
6652        command
6653    };
6654    match command {
6655        SupervisorCommand::Drain { requester, reply } => {
6656            // A plain stop runs no forwarding drain, so nothing reaches the
6657            // module over its connection before the wait: ask by signal.
6658            let result = drain_optional_child(
6659                &spec.module_id,
6660                spec.protocol,
6661                StopNotice::NotSent,
6662                registry,
6663                runtime.forwarding.as_deref(),
6664                snapshot,
6665                &runtime.terminal_ring,
6666                &runtime.spawn_events,
6667                child,
6668                runtime.drain_timeout,
6669                ModuleState::Stopped,
6670                None,
6671            )
6672            .await;
6673            let registration_released = result.is_ok();
6674            if registration_released {
6675                runtime.record_activity_transition(
6676                    "subc.module_stopped",
6677                    &spec.module_id,
6678                    requester,
6679                );
6680            }
6681            let _ = reply.send(result);
6682            if registration_released {
6683                process_liveness.untrack_if_current(&spec.module_id, snapshot);
6684            }
6685            false
6686        }
6687        SupervisorCommand::Retire { requester, reply } => {
6688            let result = async {
6689                let stop_notice = begin_forwarding_drain_if_configured(
6690                    spec,
6691                    runtime,
6692                    registry,
6693                    snapshot,
6694                    None,
6695                    RouteCloseReason::Disable,
6696                )
6697                .await?;
6698                drain_optional_child(
6699                    &spec.module_id,
6700                    spec.protocol,
6701                    stop_notice,
6702                    registry,
6703                    runtime.forwarding.as_deref(),
6704                    snapshot,
6705                    &runtime.terminal_ring,
6706                    &runtime.spawn_events,
6707                    child,
6708                    runtime.drain_timeout,
6709                    ModuleState::Stopped,
6710                    None,
6711                )
6712                .await
6713            }
6714            .await;
6715            let registration_released = result.is_ok();
6716            if registration_released {
6717                runtime.record_activity_transition(
6718                    "subc.module_stopped",
6719                    &spec.module_id,
6720                    requester,
6721                );
6722            }
6723            let _ = reply.send(result);
6724            if registration_released {
6725                process_liveness.untrack_if_current(&spec.module_id, snapshot);
6726            }
6727            false
6728        }
6729        SupervisorCommand::Restart {
6730            requester,
6731            drain_timeout_ms,
6732            received_at_generation,
6733            queued_at,
6734            reply,
6735        } => {
6736            // Without this line a restart that waited in the queue (behind a
6737            // health probe cycle or another command) was invisible: the log
6738            // showed only the drain timing out, minutes after the operator's call.
6739            info!(
6740                module_id = %spec.module_id,
6741                queued_ms = u64::try_from(queued_at.elapsed().as_millis()).unwrap_or(u64::MAX),
6742                "restart command dequeued"
6743            );
6744            // ACK AT INITIATION, not completion. The blocking form deadlocked any
6745            // caller whose own request lane rides the module being restarted: the
6746            // caller's in-flight request keeps the drain from quiescing, the drain
6747            // keeps the restart from completing, and the completion keeps the reply
6748            // from releasing the caller — so the drain always timed out and cut the
6749            // initiator with a GOODBYE, even on a healthy module. Replying once the
6750            // restart is validated lets a self-lane caller settle, which is exactly
6751            // what makes the drain succeed. Completion is observable via
6752            // supervisor.list / module status; a post-ack failure lands the module
6753            // in a visible terminal state below rather than in a reply nobody can
6754            // receive.
6755            let validation = match lock_snapshot(snapshot) {
6756                Ok(state) if !state.enabled => Err(SuperviseError::Disabled {
6757                    module_id: spec.module_id.clone(),
6758                }),
6759                Ok(_) => Ok(()),
6760                Err(err) => Err(err),
6761            };
6762            let initiated = validation.is_ok();
6763            let _ = reply.send(validation);
6764            // A restart asks for a fresh process. Commands run one at a time,
6765            // so a restart queued behind another restart (two operator calls
6766            // in quick succession) is dequeued the moment the first one has
6767            // spawned its replacement -- before that process has sent HELLO.
6768            // Running it would drain and kill the process the first restart
6769            // just produced, which is the opposite of what both callers asked
6770            // for. If a process spawned after this request was received is
6771            // still supervised, the request is already satisfied. Not when the
6772            // configuration changed since that spawn: then the newer process
6773            // predates the spec this restart may exist to apply.
6774            let satisfied_by_generation = if initiated && child.is_some() {
6775                lock_snapshot(snapshot).ok().and_then(|state| {
6776                    (state.spawn_generation > received_at_generation
6777                        && !state.configuration_updated_since_spawn)
6778                        .then_some(state.spawn_generation)
6779                })
6780            } else {
6781                None
6782            };
6783            let satisfied_by_pending = initiated
6784                && child.is_none()
6785                && lock_snapshot(snapshot).ok().is_some_and(|mut state| {
6786                    let pending = state.respawn_pending && !state.configuration_updated_since_spawn;
6787                    if pending {
6788                        state.coalesced_restart_pending = true;
6789                    }
6790                    pending
6791                });
6792            if satisfied_by_pending {
6793                debug!(module_id = %spec.module_id, "restart coalesced into the pending replacement");
6794            } else if let Some(generation) = satisfied_by_generation {
6795                info!(
6796                    module_id = %spec.module_id,
6797                    received_at_generation,
6798                    "restart already satisfied by generation {generation}; not restarting again"
6799                );
6800            } else if initiated {
6801                // Precedence: this restart's operator override, else the module's
6802                // configured budget (already resolved into the runtime).
6803                let drain_timeout = drain_timeout_ms
6804                    .map(Duration::from_millis)
6805                    .unwrap_or(runtime.drain_timeout);
6806                if let Err(err) = restart_child(
6807                    spec,
6808                    runtime,
6809                    registry,
6810                    process_liveness,
6811                    snapshot,
6812                    child,
6813                    drain_timeout,
6814                )
6815                .await
6816                {
6817                    warn!(
6818                        module_id = %spec.module_id,
6819                        error = %err,
6820                        "operator restart failed after initiation ack; module state carries the outcome"
6821                    );
6822                    let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
6823                        state.state = ModuleState::Failed;
6824                        clear_current_process_facts(state);
6825                    });
6826                } else {
6827                    runtime.start_requester = requester;
6828                    runtime.pending_activity_restart = true;
6829                }
6830            }
6831            true
6832        }
6833        SupervisorCommand::Reload { requester, reply } => {
6834            let result =
6835                reload_child(spec, runtime, registry, process_liveness, snapshot, child).await;
6836            if result.is_ok() {
6837                runtime.start_requester = requester;
6838                runtime.pending_activity_restart = true;
6839            }
6840            if result.is_ok()
6841                && runtime
6842                    .scheduled_respawn
6843                    .lock()
6844                    .unwrap_or_else(|p| p.into_inner())
6845                    .as_ref()
6846                    .is_some_and(|pending| matches!(pending.kind, RespawnKind::Reload))
6847            {
6848                *runtime
6849                    .deferred_reload_reply
6850                    .lock()
6851                    .unwrap_or_else(|p| p.into_inner()) = Some(reply);
6852            } else {
6853                let _ = reply.send(result);
6854            }
6855            true
6856        }
6857        SupervisorCommand::SetEnabled {
6858            enabled,
6859            requester,
6860            reply,
6861        } => {
6862            let previous_requester = runtime.start_requester.clone();
6863            if enabled {
6864                runtime.start_requester = requester.clone();
6865            }
6866            let result = set_child_enabled(
6867                spec,
6868                runtime,
6869                registry,
6870                process_liveness,
6871                snapshot,
6872                child,
6873                enabled,
6874            )
6875            .await;
6876            if matches!(result, Ok(true)) {
6877                runtime.pending_activity_restart = false;
6878                if enabled {
6879                    runtime.start_requester = requester;
6880                } else {
6881                    runtime.start_requester = None;
6882                    runtime.record_activity_transition(
6883                        "subc.module_disabled",
6884                        &spec.module_id,
6885                        requester,
6886                    );
6887                }
6888            } else {
6889                runtime.start_requester = previous_requester;
6890            }
6891            let _ = reply.send(result);
6892            true
6893        }
6894        SupervisorCommand::UpdateConfiguration {
6895            spec: next_spec,
6896            health,
6897            drain_timeout_ms,
6898            reply,
6899        } => {
6900            if let Some(handle) = &runtime.supervisor_handle {
6901                handle.apply_identity_configuration(&next_spec);
6902            }
6903            *spec = next_spec;
6904            let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
6905                state.configuration_updated_since_spawn = true;
6906            });
6907            let health_changed = runtime.health != health;
6908            runtime.health = health;
6909            // Reset the cadence and old endpoint's failure streak on a live
6910            // health-policy change rather than waiting for its old deadline.
6911            if health_changed {
6912                let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
6913                    state.health = ModuleHealthStatus::default();
6914                });
6915            }
6916            runtime.drain_timeout = drain_timeout_ms
6917                .map(Duration::from_millis)
6918                .unwrap_or(runtime.default_drain_timeout);
6919            *runtime
6920                .effective_drain_timeout
6921                .lock()
6922                .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.drain_timeout;
6923            let _ = reply.send(());
6924            true
6925        }
6926        SupervisorCommand::Swap {
6927            requester,
6928            ready_timeout,
6929            reply,
6930        } => {
6931            let mut swap_runtime = runtime.clone();
6932            swap_runtime.start_requester = requester;
6933            let before = lock_snapshot(snapshot)
6934                .ok()
6935                .map(|state| state.spawn_generation);
6936            let end = swap::run_swap(
6937                spec,
6938                &swap_runtime,
6939                registry,
6940                process_liveness,
6941                snapshot,
6942                child,
6943                commands,
6944                ready_timeout.unwrap_or(DEFAULT_SWAP_READY_TIMEOUT),
6945                reply,
6946            )
6947            .await;
6948            if before
6949                != lock_snapshot(snapshot)
6950                    .ok()
6951                    .map(|state| state.spawn_generation)
6952            {
6953                runtime.start_requester = swap_runtime.start_requester;
6954            }
6955            requeued.extend(end.requeue);
6956            true
6957        }
6958    }
6959}
6960
6961async fn restart_child(
6962    spec: &ModuleSpec,
6963    runtime: &SupervisorRuntimeConfig,
6964    registry: &Registry,
6965    process_liveness: &SupervisorProcessLiveness,
6966    snapshot: &SharedSnapshot,
6967    child: &mut Option<SupervisedChild>,
6968    drain_timeout: Duration,
6969) -> Result<(), SuperviseError> {
6970    // Restart cycles a running module; it must not silently start a disabled one.
6971    if !lock_snapshot(snapshot)?.enabled {
6972        return Err(SuperviseError::Disabled {
6973            module_id: spec.module_id.clone(),
6974        });
6975    }
6976    let stop_notice = begin_forwarding_drain_with_timeout(
6977        spec,
6978        runtime,
6979        registry,
6980        snapshot,
6981        None,
6982        RouteCloseReason::Restart,
6983        drain_timeout,
6984    )
6985    .await?;
6986
6987    if child.is_some() {
6988        drain_optional_child(
6989            &spec.module_id,
6990            spec.protocol,
6991            stop_notice,
6992            registry,
6993            runtime.forwarding.as_deref(),
6994            snapshot,
6995            &runtime.terminal_ring,
6996            &runtime.spawn_events,
6997            child,
6998            drain_timeout,
6999            ModuleState::Restarting,
7000            Some(true),
7001        )
7002        .await?;
7003    } else {
7004        update_snapshot(snapshot, Some(&spec.module_id), |state| {
7005            state.enabled = true;
7006            state.state = ModuleState::Restarting;
7007            clear_current_process_facts(state);
7008        })?;
7009        release_dead_registration(
7010            registry,
7011            runtime.forwarding.as_deref(),
7012            snapshot,
7013            &spec.module_id,
7014        )
7015        .await?;
7016    }
7017
7018    reset_restart_count(snapshot, &spec.module_id)?;
7019    process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
7020    schedule_respawn(
7021        runtime,
7022        snapshot,
7023        &spec.module_id,
7024        runtime.restart_policy.backoff,
7025        RespawnKind::Spawn,
7026    )
7027}
7028
7029async fn reload_child(
7030    spec: &ModuleSpec,
7031    runtime: &SupervisorRuntimeConfig,
7032    registry: &Registry,
7033    process_liveness: &SupervisorProcessLiveness,
7034    snapshot: &SharedSnapshot,
7035    child: &mut Option<SupervisedChild>,
7036) -> Result<(), SuperviseError> {
7037    // Reload cycles a running module; it must not silently start a disabled one.
7038    if !lock_snapshot(snapshot)?.enabled {
7039        return Err(SuperviseError::Disabled {
7040            module_id: spec.module_id.clone(),
7041        });
7042    }
7043    let stop_notice = begin_forwarding_drain(
7044        spec,
7045        runtime,
7046        registry,
7047        snapshot,
7048        Some(true),
7049        RouteCloseReason::Reload,
7050    )
7051    .await?;
7052
7053    if child.is_some() {
7054        drain_optional_child(
7055            &spec.module_id,
7056            spec.protocol,
7057            stop_notice,
7058            registry,
7059            runtime.forwarding.as_deref(),
7060            snapshot,
7061            &runtime.terminal_ring,
7062            &runtime.spawn_events,
7063            child,
7064            runtime.drain_timeout,
7065            ModuleState::Restarting,
7066            Some(true),
7067        )
7068        .await?;
7069    } else {
7070        update_snapshot(snapshot, Some(&spec.module_id), |state| {
7071            state.enabled = true;
7072            state.state = ModuleState::Restarting;
7073            clear_current_process_facts(state);
7074        })?;
7075        release_dead_registration(
7076            registry,
7077            runtime.forwarding.as_deref(),
7078            snapshot,
7079            &spec.module_id,
7080        )
7081        .await?;
7082    }
7083
7084    reset_restart_count(snapshot, &spec.module_id)?;
7085    process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
7086    schedule_respawn(
7087        runtime,
7088        snapshot,
7089        &spec.module_id,
7090        runtime.restart_policy.backoff,
7091        RespawnKind::Reload,
7092    )
7093}
7094
7095async fn finish_reload_child(
7096    spec: &ModuleSpec,
7097    runtime: &SupervisorRuntimeConfig,
7098    registry: &Registry,
7099    process_liveness: &SupervisorProcessLiveness,
7100    snapshot: &SharedSnapshot,
7101    child: &mut Option<SupervisedChild>,
7102) -> Result<(), SuperviseError> {
7103    process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
7104    let next_child = match spawn_and_mark_running(spec, runtime, snapshot) {
7105        Ok(next_child) => next_child,
7106        Err(err) => {
7107            return handle_reload_spawn_failure(
7108                spec,
7109                runtime,
7110                process_liveness,
7111                snapshot,
7112                child,
7113                format!("new child failed to spawn: {err}"),
7114            )
7115            .await;
7116        }
7117    };
7118    *child = Some(next_child);
7119
7120    let wait_outcome = {
7121        let active_child = child.as_mut().expect("new reload child was just stored");
7122        wait_for_registration_after_reload(
7123            registry,
7124            &spec.module_id,
7125            snapshot,
7126            active_child,
7127            REGISTRY_RELEASE_TIMEOUT,
7128        )
7129        .await?
7130    };
7131
7132    match wait_outcome {
7133        RegistrationWaitOutcome::Registered => {
7134            debug!(module_id = %spec.module_id, "supervised module reloaded and registered");
7135            Ok(())
7136        }
7137        RegistrationWaitOutcome::Exited(exit_report) => {
7138            if let Some(active_child) = child.as_mut() {
7139                active_child.drain_stderr(&spec.module_id).await;
7140            }
7141            // Keep the reaped child's roster guard until its terminal is written.
7142            // Shutdown waits on that guard, not on the child Option used for respawn.
7143            let mut exited_child = child.take().expect("exited reload child is still stored");
7144            #[cfg(test)]
7145            if let Some(gate) = &runtime.test_reload_exit_record_gate {
7146                gate.reached.notify_one();
7147                gate.resume.notified().await;
7148            }
7149            let result = handle_reload_child_registration_failure(
7150                spec,
7151                runtime,
7152                registry,
7153                process_liveness,
7154                snapshot,
7155                child,
7156                ReloadRegistrationFailure {
7157                    exit_report: registration_failure_exit_report(exit_report),
7158                    reason: exited_child
7159                        .spawn_failure
7160                        .clone()
7161                        .unwrap_or_else(|| "new child exited before registering".to_string()),
7162                },
7163            )
7164            .await;
7165            exited_child.release_roster();
7166            result
7167        }
7168        RegistrationWaitOutcome::TimedOut => {
7169            let mut timed_out_child = child
7170                .take()
7171                .expect("timed-out reload child is still running");
7172            timed_out_child
7173                .start_kill()
7174                .map_err(|source| SuperviseError::Kill {
7175                    module_id: spec.module_id.clone(),
7176                    source,
7177                })?;
7178            let status = timed_out_child
7179                .wait()
7180                .await
7181                .map_err(|source| SuperviseError::Wait {
7182                    module_id: spec.module_id.clone(),
7183                    source,
7184                })?;
7185            timed_out_child.drain_stderr(&spec.module_id).await;
7186            handle_reload_child_registration_failure(
7187                spec,
7188                runtime,
7189                registry,
7190                process_liveness,
7191                snapshot,
7192                child,
7193                ReloadRegistrationFailure {
7194                    exit_report: registration_failure_exit_report(classify_reaped_child_exit(
7195                        snapshot,
7196                        &timed_out_child,
7197                        &status,
7198                    )),
7199                    reason: format!(
7200                        "new child did not register within {:?}",
7201                        REGISTRY_RELEASE_TIMEOUT
7202                    ),
7203                },
7204            )
7205            .await
7206        }
7207    }
7208}
7209
7210async fn set_child_enabled(
7211    spec: &ModuleSpec,
7212    runtime: &SupervisorRuntimeConfig,
7213    registry: &Registry,
7214    process_liveness: &SupervisorProcessLiveness,
7215    snapshot: &SharedSnapshot,
7216    child: &mut Option<SupervisedChild>,
7217    enabled: bool,
7218) -> Result<bool, SuperviseError> {
7219    let (current_enabled, current_state, respawn_pending) = {
7220        let state = lock_snapshot(snapshot)?;
7221        (state.enabled, state.state, state.respawn_pending)
7222    };
7223    // `start` (enable on an already-enabled module) heals TERMINAL states instead
7224    // of no-op'ing: a module whose restart budget exhausted (Failed) or that exited
7225    // clean (Stopped) has no live process and no other in-band recovery — the
7226    // operator's start is the explicit recovery act and resets the budget. Without
7227    // this arm the only revival was subc-probe --supervisor-restart in a terminal,
7228    // which the 2026-07-14 aft outage proved is a trap when the failed module is
7229    // the one providing every agent's shell.
7230    let revive_terminal = enabled
7231        && current_enabled
7232        && child.is_none()
7233        && (matches!(current_state, ModuleState::Failed | ModuleState::Stopped)
7234            || (current_state == ModuleState::Restarting && !respawn_pending));
7235    if current_enabled == enabled && !revive_terminal {
7236        return Ok(false);
7237    }
7238
7239    if enabled {
7240        update_snapshot(snapshot, Some(&spec.module_id), |state| {
7241            state.enabled = true;
7242            state.state = ModuleState::Starting;
7243            clear_current_process_facts(state);
7244        })?;
7245        #[cfg(test)]
7246        if runtime.test_seed_stale_facts_before_enable_spawn {
7247            update_snapshot(snapshot, Some(&spec.module_id), |state| {
7248                state.process_alive = true;
7249                state.pid = Some(41);
7250                state.spawned_at_ms = Some(42);
7251                state.spawned_from = Some(PathBuf::from("/spawned/module"));
7252                state.spawned_file_identity = Some(SpawnedFileIdentity {
7253                    device: 43,
7254                    inode: 44,
7255                    #[cfg(windows)]
7256                    windows_image: None,
7257                });
7258            })?;
7259        }
7260        release_dead_registration(
7261            registry,
7262            runtime.forwarding.as_deref(),
7263            snapshot,
7264            &spec.module_id,
7265        )
7266        .await?;
7267        reset_restart_count(snapshot, &spec.module_id)?;
7268        process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
7269        let next_child = match spawn_and_mark_running(spec, runtime, snapshot) {
7270            Ok(next_child) => next_child,
7271            Err(err) => {
7272                if let Err(state_err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
7273                    state.state = ModuleState::Failed;
7274                    clear_current_process_facts(state);
7275                }) {
7276                    error!(module_id = %spec.module_id, error = %state_err, "failed to record enable spawn failure");
7277                }
7278                process_liveness.untrack_if_current(&spec.module_id, snapshot);
7279                return Err(err);
7280            }
7281        };
7282        *child = Some(next_child);
7283        debug!(module_id = %spec.module_id, "supervised module enabled");
7284        Ok(true)
7285    } else {
7286        let stop_notice = begin_forwarding_drain_if_configured(
7287            spec,
7288            runtime,
7289            registry,
7290            snapshot,
7291            Some(false),
7292            RouteCloseReason::Disable,
7293        )
7294        .await?;
7295        drain_optional_child(
7296            &spec.module_id,
7297            spec.protocol,
7298            stop_notice,
7299            registry,
7300            runtime.forwarding.as_deref(),
7301            snapshot,
7302            &runtime.terminal_ring,
7303            &runtime.spawn_events,
7304            child,
7305            runtime.drain_timeout,
7306            ModuleState::Disabled,
7307            Some(false),
7308        )
7309        .await?;
7310        debug!(module_id = %spec.module_id, "supervised module disabled");
7311        Ok(true)
7312    }
7313}
7314
7315#[allow(clippy::too_many_arguments)]
7316async fn on_child_exit(
7317    spec: &ModuleSpec,
7318    policy: RestartPolicy,
7319    registry: &Registry,
7320    snapshot: &SharedSnapshot,
7321    terminal_ring: &Arc<Mutex<TerminalRing>>,
7322    spawn_events: &SpawnEventFeed,
7323    roster: &ChildRoster,
7324    exit_report: ExitReport,
7325) -> NextAction {
7326    // Once the daemon has begun shutting down, no exit is a crash to recover
7327    // from: the module is exiting because the daemon is going away (EOF on its
7328    // connection, or a service manager signalling the whole cgroup). Record it
7329    // as such and never schedule a respawn, which would only start a process
7330    // for the shutdown to end again.
7331    if roster.is_closed() {
7332        return on_child_exit_during_daemon_shutdown(
7333            spec,
7334            registry,
7335            snapshot,
7336            terminal_ring,
7337            spawn_events,
7338            exit_report,
7339        )
7340        .await;
7341    }
7342    // Every stop the supervisor itself asks for (operator stop, disable,
7343    // restart, reload, swap, a health restart, a drain that runs out of budget)
7344    // takes the child out of the supervise loop and reaps it in
7345    // `drain_child_to_state`, and daemon shutdown is handled above. So an exit
7346    // that reaches this point was not requested by the daemon.
7347    //
7348    // For a subc-wire module a clean exit is still a stop: those modules are
7349    // written to re-raise SIGTERM, so a stray outside signal already reads as a
7350    // crash, and exiting 0 is a deliberate choice the module made. A
7351    // `protocol: "none"` module is a stock program we cannot change, and many
7352    // of them (nats-server among them) exit 0 on SIGTERM. Treating that as a
7353    // stop would leave the module down for good after any stray signal, so it
7354    // goes through the crash path instead: it spends restart budget, respawns
7355    // with the crash backoff, and ends `failed` when the budget runs out.
7356    let unrequested_clean_exit_of_protocol_none = exit_report.kind == ExitKind::Clean
7357        && running_protocol(spec, snapshot) == ModuleProtocol::None;
7358    match exit_report.kind {
7359        ExitKind::Clean if !unrequested_clean_exit_of_protocol_none => {
7360            info!(
7361                module_id = %spec.module_id,
7362                exit_code = ?exit_report.code,
7363                exit_signal = ?exit_report.signal,
7364                "supervised module exited cleanly"
7365            );
7366            if let Err(err) = publish_after_terminal_record(
7367                &spec.module_id,
7368                || {
7369                    record_terminal(
7370                        &spec.module_id,
7371                        terminal_ring,
7372                        spawn_events,
7373                        &exit_report,
7374                        TerminalDisposition::Stopped,
7375                    )
7376                },
7377                || {
7378                    update_snapshot(snapshot, Some(&spec.module_id), |state| {
7379                        state.state = ModuleState::Stopped;
7380                        clear_current_process_facts(state);
7381                        state.last_exit = Some(exit_report.clone());
7382                    })
7383                },
7384            ) {
7385                error!(module_id = %spec.module_id, error = %err, "failed to publish clean module exit");
7386            }
7387            let registration_released = match wait_for_registration_release(
7388                registry,
7389                &spec.module_id,
7390                REGISTRY_RELEASE_TIMEOUT,
7391            )
7392            .await
7393            {
7394                Ok(()) => true,
7395                Err(err) => {
7396                    warn!(module_id = %spec.module_id, error = %err, "registration still active after clean exit");
7397                    false
7398                }
7399            };
7400            NextAction::Stop {
7401                registration_released,
7402            }
7403        }
7404        ExitKind::Clean | ExitKind::Crash => {
7405            if unrequested_clean_exit_of_protocol_none {
7406                warn!(
7407                    module_id = %spec.module_id,
7408                    exit_code = ?exit_report.code,
7409                    exit_signal = ?exit_report.signal,
7410                    "protocol-none module exited cleanly without a stop request; handling it as a crash"
7411                );
7412            } else {
7413                warn!(
7414                    module_id = %spec.module_id,
7415                    exit_code = ?exit_report.code,
7416                    exit_signal = ?exit_report.signal,
7417                    "supervised module exited abnormally (crash)"
7418                );
7419            }
7420            let mut restart_schedule = None;
7421            let mut disposition = TerminalDisposition::Disabled;
7422            let mut final_state = ModuleState::Disabled;
7423            // Set only when the budget is what stopped the module, so the
7424            // terminal record says which limit was hit rather than leaving
7425            // `failed` to be read as "crashed once, badly".
7426            let mut disposition_detail = lock_snapshot(snapshot)
7427                .ok()
7428                .and_then(|mut state| state.spawn_failure.take());
7429            let now = Instant::now();
7430            if let Err(err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
7431                if state.enabled {
7432                    if let Some(schedule) = state.next_crash_restart(&policy, now) {
7433                        // Reserving the last slot exhausts the budget for the
7434                        // replacement, not for this exit. Publish its scheduled
7435                        // recovery atomically with the reservation so connection
7436                        // cleanup sees it while terminal recording still holds
7437                        // the visible state at Running.
7438                        state.respawn_pending = true;
7439                        final_state = ModuleState::Restarting;
7440                        restart_schedule = Some(schedule);
7441                        disposition = TerminalDisposition::Restarting;
7442                    } else {
7443                        final_state = ModuleState::Failed;
7444                        disposition = TerminalDisposition::Failed;
7445                        let budget = policy.budget_exhausted_detail();
7446                        disposition_detail =
7447                            Some(disposition_detail.take().map_or_else(
7448                                || budget.clone(),
7449                                |cause| format!("{cause}; {budget}"),
7450                            ));
7451                    }
7452                } else {
7453                    disposition = TerminalDisposition::Disabled;
7454                }
7455            }) {
7456                error!(module_id = %spec.module_id, error = %err, "failed to record crashed module exit");
7457                return NextAction::Stop {
7458                    registration_released: false,
7459                };
7460            }
7461            if disposition == TerminalDisposition::Failed {
7462                // The window is in the message, not only in the fields: this line
7463                // is read in a scrollback where a bare `max_restarts=3` reads as a
7464                // lifetime cap and sends the operator looking for three crashes
7465                // that never happened together.
7466                error!(
7467                    module_id = %spec.module_id,
7468                    max_restarts = policy.max_restarts,
7469                    window_secs = policy.window.as_secs(),
7470                    "module stopped: {}",
7471                    policy.budget_exhausted_detail()
7472                );
7473            }
7474            if let Err(err) = publish_after_terminal_record(
7475                &spec.module_id,
7476                || {
7477                    record_terminal_with_detail(
7478                        &spec.module_id,
7479                        terminal_ring,
7480                        spawn_events,
7481                        &exit_report,
7482                        disposition,
7483                        disposition_detail,
7484                    )
7485                },
7486                || {
7487                    update_snapshot(snapshot, Some(&spec.module_id), |state| {
7488                        state.state = final_state;
7489                        clear_current_process_facts(state);
7490                        state.last_exit = Some(exit_report.clone());
7491                    })
7492                },
7493            ) {
7494                error!(module_id = %spec.module_id, error = %err, "failed to publish crashed module exit");
7495            }
7496
7497            if let Some(schedule) = restart_schedule {
7498                NextAction::Restart {
7499                    schedule: Some(schedule),
7500                }
7501            } else {
7502                let registration_released = match wait_for_registration_release(
7503                    registry,
7504                    &spec.module_id,
7505                    REGISTRY_RELEASE_TIMEOUT,
7506                )
7507                .await
7508                {
7509                    Ok(()) => true,
7510                    Err(err) => {
7511                        warn!(module_id = %spec.module_id, error = %err, "registration still active after failed module");
7512                        false
7513                    }
7514                };
7515                NextAction::Stop {
7516                    registration_released,
7517                }
7518            }
7519        }
7520        ExitKind::DeliberateSeverance => {
7521            warn!(
7522                module_id = %spec.module_id,
7523                exit_code = ?exit_report.code,
7524                exit_signal = ?exit_report.signal,
7525                "supervised module exited after deliberate connection severance"
7526            );
7527            let should_restart = match lock_snapshot(snapshot) {
7528                Ok(state) => state.enabled,
7529                Err(err) => {
7530                    error!(module_id = %spec.module_id, error = %err, "failed to read deliberately severed module state");
7531                    return NextAction::Stop {
7532                        registration_released: false,
7533                    };
7534                }
7535            };
7536            let final_state = if should_restart {
7537                ModuleState::Restarting
7538            } else {
7539                ModuleState::Disabled
7540            };
7541            if let Err(err) = publish_after_terminal_record(
7542                &spec.module_id,
7543                || {
7544                    record_terminal(
7545                        &spec.module_id,
7546                        terminal_ring,
7547                        spawn_events,
7548                        &exit_report,
7549                        terminal_disposition(final_state),
7550                    )
7551                },
7552                || {
7553                    update_snapshot(snapshot, Some(&spec.module_id), |state| {
7554                        state.state = final_state;
7555                        clear_current_process_facts(state);
7556                        state.last_exit = Some(exit_report.clone());
7557                        state.lifetime_restarts += 1;
7558                    })
7559                },
7560            ) {
7561                error!(module_id = %spec.module_id, error = %err, "failed to publish deliberately severed module exit");
7562                return NextAction::Stop {
7563                    registration_released: false,
7564                };
7565            }
7566
7567            if should_restart {
7568                NextAction::Restart { schedule: None }
7569            } else {
7570                let registration_released = match wait_for_registration_release(
7571                    registry,
7572                    &spec.module_id,
7573                    REGISTRY_RELEASE_TIMEOUT,
7574                )
7575                .await
7576                {
7577                    Ok(()) => true,
7578                    Err(err) => {
7579                        warn!(module_id = %spec.module_id, error = %err, "registration still active after deliberately severed module exit");
7580                        false
7581                    }
7582                };
7583                NextAction::Stop {
7584                    registration_released,
7585                }
7586            }
7587        }
7588    }
7589}
7590
7591async fn on_child_exit_during_daemon_shutdown(
7592    spec: &ModuleSpec,
7593    registry: &Registry,
7594    snapshot: &SharedSnapshot,
7595    terminal_ring: &Arc<Mutex<TerminalRing>>,
7596    spawn_events: &SpawnEventFeed,
7597    exit_report: ExitReport,
7598) -> NextAction {
7599    info!(
7600        module_id = %spec.module_id,
7601        exit_code = ?exit_report.code,
7602        exit_signal = ?exit_report.signal,
7603        exit_kind = ?exit_report.kind,
7604        "supervised module exited during daemon shutdown; not restarting it"
7605    );
7606    if let Err(err) = publish_after_terminal_record(
7607        &spec.module_id,
7608        || {
7609            record_terminal(
7610                &spec.module_id,
7611                terminal_ring,
7612                spawn_events,
7613                &exit_report,
7614                TerminalDisposition::DaemonShutdown,
7615            )
7616        },
7617        || {
7618            update_snapshot(snapshot, Some(&spec.module_id), |state| {
7619                state.state = ModuleState::Stopped;
7620                clear_current_process_facts(state);
7621                state.last_exit = Some(exit_report.clone());
7622            })
7623        },
7624    ) {
7625        error!(module_id = %spec.module_id, error = %err, "failed to publish module exit during daemon shutdown");
7626    }
7627    let registration_released =
7628        wait_for_registration_release(registry, &spec.module_id, REGISTRY_RELEASE_TIMEOUT)
7629            .await
7630            .is_ok();
7631    NextAction::Stop {
7632        registration_released,
7633    }
7634}
7635
7636// Keep the status lock out of terminal I/O. Readers may observe the previous
7637// state while recording is in progress, but never a completed exit without its
7638// history. Journal errors are logged by the journal and retained in the ring;
7639// a recording panic still publishes the final state before resuming the unwind.
7640fn publish_after_terminal_record<T>(
7641    module_id: &str,
7642    record: impl FnOnce(),
7643    publish: impl FnOnce() -> T,
7644) -> T {
7645    let recorded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(record));
7646    let published = publish();
7647    if let Err(panic) = recorded {
7648        error!(
7649            module_id,
7650            "terminal recording panicked; published final state before resuming unwind"
7651        );
7652        std::panic::resume_unwind(panic);
7653    }
7654    published
7655}
7656
7657fn fail_after_wait_error(
7658    module_id: &str,
7659    snapshot: &SharedSnapshot,
7660    terminal_ring: &Arc<Mutex<TerminalRing>>,
7661    spawn_events: &SpawnEventFeed,
7662    detail: String,
7663) {
7664    let report = wait_error_exit_report();
7665    publish_after_terminal_record(
7666        module_id,
7667        || {
7668            record_terminal_with_detail(
7669                module_id,
7670                terminal_ring,
7671                spawn_events,
7672                &report,
7673                TerminalDisposition::Failed,
7674                Some(detail),
7675            )
7676        },
7677        || fail_snapshot(snapshot, Some(module_id), Some(report.clone())),
7678    );
7679}
7680
7681#[cfg(test)]
7682fn record_wait_error_terminal(
7683    module_id: &str,
7684    terminal_ring: &Arc<Mutex<TerminalRing>>,
7685    spawn_events: &SpawnEventFeed,
7686) {
7687    record_terminal(
7688        module_id,
7689        terminal_ring,
7690        spawn_events,
7691        &wait_error_exit_report(),
7692        TerminalDisposition::Failed,
7693    );
7694}
7695
7696fn record_terminal(
7697    module_id: &str,
7698    terminal_ring: &Arc<Mutex<TerminalRing>>,
7699    spawn_events: &SpawnEventFeed,
7700    exit_report: &ExitReport,
7701    disposition: TerminalDisposition,
7702) {
7703    record_terminal_with_detail(
7704        module_id,
7705        terminal_ring,
7706        spawn_events,
7707        exit_report,
7708        disposition,
7709        None,
7710    );
7711}
7712
7713/// The ring lock is held only to capture the read (see
7714/// `TerminalJournal::capture_read`), so this module's exits keep recording
7715/// while the journal files are read. Blocking: it reads files.
7716fn durable_terminal_history_of(
7717    terminal_ring: &Mutex<TerminalRing>,
7718    module_id: &str,
7719) -> subc_control::TerminalHistory {
7720    let read = terminal_ring
7721        .lock()
7722        .unwrap_or_else(|p| p.into_inner())
7723        .capture_durable_history();
7724    read.read(module_id)
7725}
7726
7727fn record_terminal_with_detail(
7728    module_id: &str,
7729    terminal_ring: &Arc<Mutex<TerminalRing>>,
7730    spawn_events: &SpawnEventFeed,
7731    exit_report: &ExitReport,
7732    disposition: TerminalDisposition,
7733    disposition_detail: Option<String>,
7734) {
7735    #[cfg(test)]
7736    terminal_history_tests::before_terminal_record(spawn_events);
7737    spawn_events.emit_exited(module_id, exit_report.code, exit_report.signal);
7738    let record = TerminalRecord {
7739        exit_code: exit_report.code,
7740        exit_signal: exit_report.signal,
7741        at_ms: exit_report.at_ms,
7742        disposition,
7743        exit_kind: exit_report.kind.into(),
7744        disposition_detail,
7745    };
7746    terminal_ring
7747        .lock()
7748        .unwrap_or_else(|poisoned| poisoned.into_inner())
7749        .record_exit(module_id, record);
7750}
7751
7752fn untrack_if_registration_released(
7753    process_liveness: &SupervisorProcessLiveness,
7754    registry: &Registry,
7755    module_id: &str,
7756    snapshot: &SharedSnapshot,
7757) {
7758    match registry.get_module(module_id) {
7759        Ok(None) => process_liveness.untrack_if_current(module_id, snapshot),
7760        Ok(Some(_)) => {}
7761        Err(err) => {
7762            warn!(module_id, error = %err, "could not determine whether supervisor liveness can be untracked");
7763        }
7764    }
7765}
7766
7767/// The child's environment plan: inherit the parent's, drop ambient `CK_LOG`,
7768/// then apply the module's configured entries minus daemon-private capture keys.
7769///
7770/// Separated from `spawn_child` only so it can be asserted without spawning a
7771/// process — a duplicate of this logic in a test would pass while the real one
7772/// drifted, which is the defect class this function exists to avoid.
7773/// The subc-wire half of a spawn: `--subc <connection file>` and the launch
7774/// nonce. A `protocol: "none"` module gets neither, because it cannot use
7775/// either and the argument would stop a stock binary from starting at all.
7776/// `SUBC_MODULE_ID` is set on every path since an unread variable is inert.
7777///
7778/// The plain-spawn form, kept for the tests that assert its plan; spawns go
7779/// through [`apply_wire_spawn_args_for_role`].
7780#[cfg(test)]
7781fn apply_wire_spawn_args(
7782    command: &mut Command,
7783    spec: &ModuleSpec,
7784    connection_file_path: Option<&std::path::Path>,
7785    handle: Option<&SupervisorHandle>,
7786) -> Result<Option<NonceHandoff>, SuperviseError> {
7787    apply_wire_spawn_args_for_role(
7788        command,
7789        spec,
7790        connection_file_path,
7791        handle,
7792        SpawnRole::Plain,
7793    )
7794}
7795
7796/// The read end of a spawn's launch-nonce pipe, prepared by
7797/// [`apply_wire_spawn_args_for_role`] and installed as the child's descriptor 3
7798/// by the last pre-exec step, just before `spawn()`. Windows prepares a named
7799/// server before spawn and authenticates its client after the child exists.
7800#[cfg(unix)]
7801type NonceHandoff = subc_os::LaunchNonceHandoff;
7802#[cfg(windows)]
7803type NonceHandoff = subc_os::LaunchNoncePipeHandoff;
7804#[cfg(not(any(unix, windows)))]
7805type NonceHandoff = std::convert::Infallible;
7806
7807/// Create the launch nonce that a new module process must present when it
7808/// registers or opens a consumer connection, including a replacement process
7809/// started alongside a still-serving module.
7810///
7811/// A plain spawn replaces the module's recorded nonce. A swap candidate records
7812/// a separate candidate token so the still-serving incumbent and its consumers
7813/// keep their nonce. Both records are installed before the process exists, so
7814/// the child's initial HELLO registration cannot arrive ahead of its nonce.
7815///
7816/// On Unix the nonce is delivered only through a pipe. It is written into
7817/// a pipe whose read end the child gets as descriptor 3, named by
7818/// `SUBC_LAUNCH_NONCE_FD=3:<pipe inode>`: unlike the environment, another
7819/// process of the same user cannot read it with `ps eww`. That handoff is
7820/// returned rather than installed here, because installing it replaces
7821/// whatever the child has at descriptor 3 and so must be the last pre-exec
7822/// step. Later in `spawn_child_in_slot`, `apply_cgroup_placement` first registers
7823/// the Linux callback that writes the process into its cgroup. Only after that
7824/// does `handoff.install_last` register the descriptor-3 replacement, so it
7825/// cannot close a descriptor the cgroup callback still needs.
7826/// Windows keeps SUBC_LAUNCH_NONCE as well until every module reads the named pipe.
7827fn apply_wire_spawn_args_for_role(
7828    command: &mut Command,
7829    spec: &ModuleSpec,
7830    connection_file_path: Option<&std::path::Path>,
7831    handle: Option<&SupervisorHandle>,
7832    role: SpawnRole,
7833) -> Result<Option<NonceHandoff>, SuperviseError> {
7834    command.env(SUBC_MODULE_ID_ENV, &spec.module_id);
7835    // SUBC_LAUNCH_NONCE_FD is removed for every child, `protocol: "none"`
7836    // included: a daemon started from a module's process tree inherits it,
7837    // and passing it on would point the child at a descriptor it does not
7838    // have.
7839    command.env_remove(subc_os::LAUNCH_NONCE_FD_ENV);
7840    #[cfg(windows)]
7841    {
7842        command.env_remove(subc_os::LAUNCH_NONCE_PIPE_ENV);
7843        command.env_remove(subc_os::LAUNCH_NONCE_PIPE_FALLBACK_ENV);
7844    }
7845    // Remove inherited or configured copies too: withholding must mean absent.
7846    command.env_remove(SUBC_LAUNCH_NONCE_ENV);
7847    if spec.protocol == ModuleProtocol::None {
7848        return Ok(None);
7849    }
7850    if let Some(connection_file_path) = connection_file_path {
7851        command.arg(SUBC_ARG).arg(connection_file_path);
7852    }
7853
7854    // Every subc-wire spawn receives a fresh one-time launch nonce for consumer
7855    // route.open attestation. Reserved modules additionally use the same nonce
7856    // for HELLO id-squatting protection. A respawn rotates both records.
7857    let nonce = generate_launch_nonce()?;
7858    if let Some(handle) = handle {
7859        match role {
7860            SpawnRole::Plain => {
7861                handle.set_spawn_nonce(&spec.module_id, nonce.clone());
7862                if spec.reserved {
7863                    handle.set_reserved_nonce(&spec.module_id, nonce.clone());
7864                }
7865            }
7866            SpawnRole::SwapCandidate => handle.open_swap(&spec.module_id, nonce.clone()),
7867        }
7868    }
7869    #[cfg(unix)]
7870    let handoff = {
7871        let handoff =
7872            subc_os::LaunchNonceHandoff::new(&nonce).map_err(|source| SuperviseError::Spawn {
7873                program: spec.program.clone(),
7874                source,
7875                cgroup_path: None,
7876            })?;
7877        command.env(subc_os::LAUNCH_NONCE_FD_ENV, handoff.fd_env_value());
7878        Some(handoff)
7879    };
7880    #[cfg(windows)]
7881    let handoff = prepare_windows_nonce_pipe(command, spec, &nonce);
7882    #[cfg(not(any(unix, windows)))]
7883    let handoff = None;
7884    // Older modules still read SUBC_LAUNCH_NONCE to register. Keep that copy
7885    // until the daemon's per-spawn delivery records show that every running
7886    // Windows module has read its secret from the named pipe.
7887    #[cfg(not(unix))]
7888    command.env(SUBC_LAUNCH_NONCE_ENV, nonce);
7889    Ok(handoff)
7890}
7891
7892fn apply_child_env(command: &mut Command, spec: &ModuleSpec) {
7893    command.env_remove(CK_LOG_ENV);
7894    // The spawn role is the supervisor's to set, and only on a swap candidate
7895    // (see `apply_spawn_role`). Removing it here, rather than just not setting
7896    // it, is what makes it absent on a plain spawn: the daemon's own
7897    // environment could carry it, and so could a spec built outside daemon
7898    // config (config refuses it as an `env` key). A module reading it on a
7899    // plain restart would pick the long swap budget and leave callers waiting.
7900    command.env_remove(SUBC_SPAWN_ROLE_ENV);
7901    for (key, value) in &spec.env {
7902        // cortexkit-log currently exposes retention only as a Rust struct, not
7903        // environment names. These values are daemon-private sink metadata and
7904        // must never become a public child-process contract by being inherited.
7905        if matches!(
7906            key.as_str(),
7907            CAPTURE_MAX_FILE_MB_ENV | CAPTURE_KEEP_ENV | CAPTURE_MAX_AGE_DAYS_ENV
7908        ) || key == SUBC_SPAWN_ROLE_ENV
7909        {
7910            continue;
7911        }
7912        command.env(key, value);
7913    }
7914}
7915
7916#[cfg(windows)]
7917fn prepare_windows_nonce_pipe(
7918    command: &mut Command,
7919    spec: &ModuleSpec,
7920    nonce: &str,
7921) -> Option<NonceHandoff> {
7922    match subc_os::LaunchNoncePipeHandoff::new(nonce) {
7923        Ok(handoff) => {
7924            command.env(subc_os::LAUNCH_NONCE_PIPE_ENV, handoff.name());
7925            // Let a failed pipe read use SUBC_LAUNCH_NONCE so the process can
7926            // still register until every module reads the pipe. A reader without
7927            // this variable set to env returns the pipe error instead of using
7928            // an inherited or configured environment copy.
7929            command.env(subc_os::LAUNCH_NONCE_PIPE_FALLBACK_ENV, "env");
7930            Some(handoff)
7931        }
7932        Err(error) => {
7933            warn!(module_id = %spec.module_id, %error, "could not prepare launch nonce pipe; retaining the environment handoff");
7934            None
7935        }
7936    }
7937}
7938
7939#[cfg(windows)]
7940fn start_windows_nonce_pipe(
7941    handoff: Option<NonceHandoff>,
7942    pid: u32,
7943    spec: &ModuleSpec,
7944) -> Option<subc_os::LaunchNoncePipeDelivery> {
7945    // Close an unread pipe after REGISTRY_RELEASE_TIMEOUT (one second), the
7946    // same maximum wait used for registration during a reload. Older modules
7947    // may register using SUBC_LAUNCH_NONCE without ever connecting to the pipe.
7948    handoff.and_then(|handoff| match handoff.serve(pid, REGISTRY_RELEASE_TIMEOUT) {
7949        Ok(delivery) => Some(delivery),
7950        Err(error) => {
7951            warn!(module_id = %spec.module_id, %error, "could not serve launch nonce pipe; retaining the environment handoff");
7952            None
7953        }
7954    })
7955}
7956
7957fn observed_launch_nonce_source(snapshot: &SupervisorSnapshot) -> Option<String> {
7958    #[cfg(windows)]
7959    if snapshot.process_alive && snapshot.spawned_protocol == Some(ModuleProtocol::Subc) {
7960        return Some(
7961            if snapshot
7962                .pipe_consumed
7963                .as_ref()
7964                .is_some_and(|consumed| consumed.get().is_some())
7965            {
7966                "pipe"
7967            } else {
7968                "env"
7969            }
7970            .to_owned(),
7971        );
7972    }
7973    let _ = snapshot;
7974    None
7975}
7976
7977/// Which slot a spawn fills: the module's ordinary one, or the candidate slot
7978/// of a blue/green swap.
7979#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7980enum SpawnRole {
7981    Plain,
7982    SwapCandidate,
7983}
7984
7985/// Set the spawn role for a swap candidate. A plain spawn gets nothing here;
7986/// `apply_child_env` has already removed the variable for every spawn.
7987fn apply_spawn_role(command: &mut Command, role: SpawnRole) {
7988    if role == SpawnRole::SwapCandidate {
7989        command.env(SUBC_SPAWN_ROLE_ENV, SPAWN_ROLE_SWAP_CANDIDATE);
7990    }
7991}
7992
7993#[cfg(target_os = "linux")]
7994fn previous_instance_error(spec: &ModuleSpec, pid: u32) -> SuperviseError {
7995    SuperviseError::Spawn {
7996        program: spec.program.clone(),
7997        source: io::Error::new(io::ErrorKind::WouldBlock, format!(
7998            "waiting: previous instance (pid {pid}) is still running but hidden; it cannot be identified"
7999        )),
8000        cgroup_path: None,
8001    }
8002}
8003
8004fn spawn_child(
8005    spec: &ModuleSpec,
8006    connection_file_path: Option<&std::path::Path>,
8007    handle: Option<&SupervisorHandle>,
8008    ring: &Arc<Mutex<StderrRing>>,
8009    capture_logs_dir: Option<&std::path::Path>,
8010    roster: &ChildRoster,
8011    #[cfg(target_os = "linux")] cgroup_placement: Option<&subc_cgroup::Placement>,
8012) -> Result<SupervisedChild, SuperviseError> {
8013    spawn_child_in_slot(
8014        spec,
8015        connection_file_path,
8016        handle,
8017        ring,
8018        capture_logs_dir,
8019        roster,
8020        #[cfg(target_os = "linux")]
8021        cgroup_placement,
8022        SpawnRole::Plain,
8023        false,
8024    )
8025}
8026
8027/// Spawn one process of `spec` into a slot.
8028///
8029/// `alternate_slot` picks the process's cgroup label on Linux (see `swap::cgroup_name`).
8030/// A swap candidate needs a different cgroup from the process it is replacing,
8031/// which is still alive: in the same cgroup the two would be one kill domain,
8032/// and killing a failed candidate could take the incumbent with it.
8033///
8034/// The stderr capture file is `<module_id>.stderr.log` for every process of
8035/// the module, whichever slot it is in, because that is the one file
8036/// `ck module logs` reads. During a swap's overlap both processes append to it;
8037/// the daemon writes whole lines, so the two interleave by line, which is also
8038/// the merged view an operator wants while a swap runs.
8039#[allow(clippy::too_many_arguments)]
8040fn spawn_child_in_slot(
8041    spec: &ModuleSpec,
8042    connection_file_path: Option<&std::path::Path>,
8043    handle: Option<&SupervisorHandle>,
8044    ring: &Arc<Mutex<StderrRing>>,
8045    capture_logs_dir: Option<&std::path::Path>,
8046    roster: &ChildRoster,
8047    #[cfg(target_os = "linux")] cgroup_placement: Option<&subc_cgroup::Placement>,
8048    role: SpawnRole,
8049    alternate_slot: bool,
8050) -> Result<SupervisedChild, SuperviseError> {
8051    #[cfg(target_os = "linux")]
8052    if let Some(pid) = roster.previous_instance_pid(&spec.module_id) {
8053        return Err(previous_instance_error(spec, pid));
8054    }
8055    if roster.is_closed() {
8056        return Err(SuperviseError::Spawn {
8057            program: spec.program.clone(),
8058            source: io::Error::other("the daemon is shutting down; not starting a new process"),
8059            cgroup_path: None,
8060        });
8061    }
8062    #[cfg(target_os = "linux")]
8063    let cgroup_name = {
8064        // Slot names alone are not kill domains: a retired incumbent may still
8065        // be draining when a later enable/restart spawns into the same slot.
8066        // Decimal entropy keeps the suffix unambiguous; Placement performs
8067        // the module-id escaping and constructs the filesystem path.
8068        if cgroup_placement.is_none() {
8069            swap::cgroup_name(&spec.module_id, alternate_slot)
8070        } else {
8071            let nonce = generate_launch_nonce()?;
8072            let suffix = u128::from_str_radix(&nonce[..32], 16).expect("hex launch nonce");
8073            // Leave room for byte escaping and the suffix under NAME_MAX. The
8074            // label is only for humans; the nonce identifies the kill domain.
8075            let mut end = spec.module_id.len().min(64);
8076            while !spec.module_id.is_char_boundary(end) {
8077                end -= 1;
8078            }
8079            format!(
8080                "{}_{suffix}",
8081                swap::cgroup_name(&spec.module_id[..end], alternate_slot)
8082            )
8083        }
8084    };
8085    #[cfg(not(target_os = "linux"))]
8086    let _ = alternate_slot;
8087    #[cfg(target_os = "macos")]
8088    let (mut command, privacy_exec, exec_ack) = privacy_command(spec, roster)?;
8089    #[cfg(windows)]
8090    let image_capture = windows_spawn_capture(spec).map_err(|source| SuperviseError::Spawn {
8091        program: spec.program.clone(),
8092        source,
8093        cgroup_path: None,
8094    })?;
8095    #[cfg(windows)]
8096    let mut command = Command::new(image_capture.path());
8097    #[cfg(not(any(target_os = "macos", windows)))]
8098    let mut command = Command::new(&spec.program);
8099    command.args(&spec.args);
8100    // AMBIENT `CK_LOG` MUST NOT LEAK INTO AN OTHERWISE UNCONFIGURED MODULE — but
8101    // that is the whole of the intent, so remove that one key rather than the
8102    // environment.
8103    //
8104    // This was `env_clear()` from 0.17.41 until 0.18.3, which achieved the goal
8105    // and took the POSIX environment with it. Modules spawned that way had no
8106    // HOME, XDG_RUNTIME_DIR, TMPDIR or USER, and the consequences ran past
8107    // logging:
8108    //
8109    //   * `connection_file::discover` reads XDG_RUNTIME_DIR and HOME, so with
8110    //     both unset it fell back to the temp dir alone and `ck` could not find
8111    //     a daemon running on the same machine from inside any module's process
8112    //     tree — reporting a path the file has never lived at, which reads as
8113    //     "the daemon did not write its file".
8114    //   * `default_data_home()` with HOME and XDG_DATA_HOME both unset returns
8115    //     the RELATIVE `.local/share`, so a module deriving its own store path
8116    //     resolved it against its own CWD. That is the store-fragmentation
8117    //     defect the daemon already refuses in config (`parse_doc` rejects a
8118    //     relative `storage.data_home`) arriving by derivation instead.
8119    //   * anything a module spawns inherited it: git without ~/.gitconfig,
8120    //     cargo without CARGO_HOME, ssh, python user dirs — all degrading
8121    //     quietly rather than erroring.
8122    //
8123    // Reported by iceteaSA as #104 after deploying 0.18.2, where `ck daemon`
8124    // offered one candidate under /tmp while the file sat in /run/user/1000.
8125    //
8126    // A configured module is unaffected either way: `module_spec()` puts the
8127    // resolved CK_LOG into `spec.env`, which is applied below and therefore
8128    // wins over anything ambient.
8129    apply_child_env(&mut command, spec);
8130    apply_spawn_role(&mut command, role);
8131    let nonce_handoff =
8132        apply_wire_spawn_args_for_role(&mut command, spec, connection_file_path, handle, role)?;
8133
8134    #[cfg(target_os = "linux")]
8135    let cgroup_path = cgroup_placement
8136        .map(|placement| placement.module_path(&cgroup_name))
8137        .transpose()
8138        .map_err(|source| SuperviseError::Cgroup {
8139            module_id: spec.module_id.clone(),
8140            source,
8141        })?;
8142    #[cfg(not(target_os = "linux"))]
8143    let cgroup_path: Option<PathBuf> = None;
8144    #[cfg(target_os = "linux")]
8145    if let Some(path) = &cgroup_path {
8146        if let Err(error) = apply_cgroup_placement(&mut command, spec, path) {
8147            if let Some(placement) = cgroup_placement {
8148                remove_module_cgroup(placement, &cgroup_name);
8149            }
8150            return Err(error);
8151        }
8152    }
8153
8154    let output_sink = if let Some(logs_dir) = capture_logs_dir {
8155        let path = logs_dir.join(format!("{}.stderr.log", spec.module_id));
8156        match ChildOutputSink::open(&path, capture_retention(spec)) {
8157            Ok(sink) => sink,
8158            Err(error) => {
8159                warn!(
8160                    module_id = %spec.module_id,
8161                    path = %path.display(),
8162                    error = %error,
8163                    "could not open child output capture file; forwarding to stderr"
8164                );
8165                ChildOutputSink::Stderr
8166            }
8167        }
8168    } else {
8169        ChildOutputSink::Stderr
8170    };
8171
8172    command.stdout(Stdio::piped());
8173    command.stderr(Stdio::piped());
8174    command.kill_on_drop(true);
8175    // EACH MODULE LEADS ITS OWN PROCESS GROUP (the child calls setpgid(0, 0)
8176    // before exec). In the daemon's group, a service manager that kills the
8177    // job's process group when the daemon exits (launchd's default) killed
8178    // every module at the same moment its control connection closed, so no
8179    // module ever ran its EOF teardown on a daemon stop. Outside that group a
8180    // module is reached only by the daemon: the EOF it sees when its
8181    // connection closes, and the bounded stop in `child_roster` for anything
8182    // still running after that. On Linux this composes with the cgroup
8183    // placement above: that is a pre_exec write to cgroup.procs, std performs
8184    // setpgid in the child before running pre_exec callbacks, and the two
8185    // change independent process attributes.
8186    //
8187    // stdin is /dev/null because a process outside the terminal's foreground
8188    // group is stopped (SIGTTIN) if it reads the terminal, which a daemon run
8189    // by hand would otherwise hand down. Under a service manager stdin is
8190    // already /dev/null.
8191    #[cfg(unix)]
8192    command.process_group(0);
8193    command.stdin(Stdio::null());
8194    // The LAST pre-exec step, after the cgroup placement above: installing the
8195    // nonce at descriptor 3 replaces whatever the child had there, which could
8196    // be the descriptor an earlier step writes through.
8197    #[cfg(unix)]
8198    if let Some(handoff) = nonce_handoff {
8199        handoff.install_last(command.as_std_mut());
8200    }
8201    #[cfg(not(any(unix, windows)))]
8202    let _ = nonce_handoff;
8203
8204    // Containment, step 1 of 3 (issue #109): create the child suspended so it
8205    // cannot run a single instruction -- and therefore cannot spawn a
8206    // grandchild -- before it is in the job. See `contain_spawned_child` for the
8207    // other two steps and why the window matters.
8208    #[cfg(windows)]
8209    subc_jobobject::suspend_on_create_async(&mut command);
8210    let mut child = match command.spawn() {
8211        Ok(child) => child,
8212        Err(source) => {
8213            #[cfg(target_os = "linux")]
8214            if let Some(placement) = cgroup_placement {
8215                remove_module_cgroup(placement, &cgroup_name);
8216            }
8217            return Err(SuperviseError::Spawn {
8218                program: spec.program.clone(),
8219                source,
8220                cgroup_path,
8221            });
8222        }
8223    };
8224    // The parent must close its writer now: the acknowledgement pipe reports EOF
8225    // only when every writer is gone, and the child's copy closes when the
8226    // trampoline replaces itself with the module. Command holds only an integer
8227    // in its pre_exec callback, not another writer.
8228    #[cfg(target_os = "macos")]
8229    drop(exec_ack);
8230
8231    // Bind while the child is still suspended, before job assignment resumes it.
8232    // Keeping the executable handle open across CreateProcessW denies rename
8233    // and deletion, so the file cannot be exchanged during spawn. Bind that
8234    // file ID to the process handle returned by spawn, never a reopened PID.
8235    #[cfg(windows)]
8236    let windows_image =
8237        Arc::new(
8238            image_capture
8239                .bind_tokio(&child)
8240                .map_err(|source| SuperviseError::Spawn {
8241                    program: spec.program.clone(),
8242                    source,
8243                    cgroup_path: None,
8244                })?,
8245        );
8246    // Containment, steps 2 and 3: assign while suspended, then resume.
8247    #[cfg(windows)]
8248    let job = contain_spawned_child(&child, spec)?;
8249    let spawned_at_ms = unix_ms_now();
8250    #[cfg(not(windows))]
8251    let spawned_from = spec.program.clone();
8252    #[cfg(windows)]
8253    let spawned_from = windows_image.path().to_path_buf();
8254    #[cfg(not(windows))]
8255    let spawned_file_identity = spawned_file_identity(&spawned_from);
8256    #[cfg(windows)]
8257    let spawned_file_identity = Some(SpawnedFileIdentity {
8258        device: 0,
8259        inode: 0,
8260        windows_image: Some(Arc::clone(&windows_image)),
8261    });
8262    let pid = child.id().ok_or_else(|| SuperviseError::Spawn {
8263        program: spec.program.clone(),
8264        source: io::Error::other("spawned child exposed no live pid"),
8265        cgroup_path: cgroup_path.clone(),
8266    })?;
8267    #[cfg(windows)]
8268    let nonce_delivery = start_windows_nonce_pipe(nonce_handoff, pid, spec);
8269    #[cfg(not(windows))]
8270    let process_start_time = crate::provenance::process_start_time(pid);
8271    #[cfg(windows)]
8272    let process_start_time = Some(windows_image.start_time());
8273    let process_identity = process_start_time.map(|start_time| ProcessIdentity { pid, start_time });
8274    #[cfg(all(test, target_os = "macos"))]
8275    privacy_exec_boundary_tests::before_image_sample(spec, pid);
8276    // Unix spawn returns after exec's error pipe closes. The kernel image is
8277    // therefore the executable to compare during a future orphan sweep: PATH
8278    // lookup and shebang interpretation may select a different file from the
8279    // configured program. Keep the literal program's identity for provenance,
8280    // but never use it as proof that a recorded pid may be signalled.
8281    #[cfg(not(windows))]
8282    let recorded_image = observe_spawned_image(pid);
8283    // spawn() confirms only the first exec, into the trampoline. Never persist
8284    // the trampoline image; the asynchronous acknowledgement publishes the
8285    // module image once the trampoline has replaced itself with the module.
8286    #[cfg(target_os = "macos")]
8287    let recorded_image = if privacy_exec.is_some() {
8288        None
8289    } else {
8290        recorded_image
8291    };
8292    #[cfg(target_os = "linux")]
8293    let recorded_cgroup_name = cgroup_path.as_ref().map(|_| cgroup_name.clone());
8294    #[cfg(not(target_os = "linux"))]
8295    let recorded_cgroup_name = None;
8296    let roster_guard = roster.admit(
8297        spec.module_id.clone(),
8298        pid,
8299        spec.protocol,
8300        process_start_time,
8301        crate::child_roster::RecordedIdentity {
8302            #[cfg(not(windows))]
8303            start_time: recorded_image.map(|image| image.start_time),
8304            #[cfg(windows)]
8305            start_time: process_start_time,
8306            #[cfg(not(windows))]
8307            executable: recorded_image
8308                .and_then(|image| image.executable)
8309                .map(crate::live_children::ExecutableIdentity::from),
8310            #[cfg(windows)]
8311            executable: Some(crate::live_children::ExecutableIdentity::from(
8312                windows_image.identity(),
8313            )),
8314            cgroup_name: recorded_cgroup_name,
8315            #[cfg(target_os = "linux")]
8316            cgroup_placement: cgroup_placement.cloned(),
8317        },
8318    );
8319    // The check at the top of this function can pass just before daemon
8320    // shutdown begins, and the process is only in the roster from here on.
8321    // The shutdown stop returns as soon as it finds the roster empty, so a
8322    // process admitted after that look would outlive the daemon. The roster
8323    // is closed before the stop first reads it and admission happens under
8324    // the roster's lock, so either the stop sees this process or this check
8325    // sees the roster closed: end the process now rather than start a module
8326    // the daemon is about to stop.
8327    if roster.is_closed() {
8328        // This child was never admitted, so there is no module protocol shutdown to wait for.
8329        #[cfg(target_os = "linux")]
8330        kill_module_cgroup(cgroup_placement, &cgroup_name);
8331        if let Err(error) = child.start_kill() {
8332            debug!(module_id = %spec.module_id, pid, %error, "kill of a process spawned during daemon shutdown failed; it may already have exited");
8333        }
8334        #[cfg(target_os = "linux")]
8335        if let Some(placement) = cgroup_placement {
8336            // This spawn was never admitted, so shutdown has no roster entry
8337            // to await. Do not detach its cleanup: the runtime could exit
8338            // before that task reaps the rejected child and removes its group.
8339            while matches!(child.try_wait(), Ok(None)) {
8340                std::thread::yield_now();
8341            }
8342            if matches!(
8343                subc_cgroup::kill_module(Some(placement), &cgroup_name),
8344                subc_cgroup::KillOutcome::Killed
8345            ) {
8346                if let Ok(path) = placement.module_path(&cgroup_name) {
8347                    while std::fs::read_to_string(path.join("cgroup.events"))
8348                        .ok()
8349                        .is_some_and(|events| events.lines().any(|line| line == "populated 1"))
8350                    {
8351                        std::thread::yield_now();
8352                    }
8353                }
8354            }
8355            remove_module_cgroup(placement, &cgroup_name);
8356        }
8357        drop(roster_guard);
8358        return Err(SuperviseError::Spawn {
8359            program: spec.program.clone(),
8360            source: io::Error::other(
8361                "the daemon began shutting down while this process was starting; ended it",
8362            ),
8363            cgroup_path,
8364        });
8365    }
8366
8367    let stdout_pump = match child.stdout.take() {
8368        Some(stdout) => Some(tokio::spawn(pump_stdout_to(stdout, output_sink.clone()))),
8369        None => {
8370            warn!(
8371                module_id = %spec.module_id,
8372                "spawned child exposed no stdout pipe; file capture will be incomplete"
8373            );
8374            None
8375        }
8376    };
8377    let stderr_pump = match child.stderr.take() {
8378        Some(stderr) => {
8379            let generation = ring
8380                .lock()
8381                .unwrap_or_else(|poisoned| poisoned.into_inner())
8382                .begin_process();
8383            Some(StderrPump {
8384                task: tokio::spawn(pump_stderr_to(
8385                    stderr,
8386                    Arc::clone(ring),
8387                    generation,
8388                    output_sink,
8389                )),
8390                generation,
8391            })
8392        }
8393        None => {
8394            // Spawning succeeded but the pipe did not materialise. Recording it as
8395            // uncaptured keeps the tail honest: the alternative is an empty tail
8396            // that reads as a module which printed nothing.
8397            ring.lock()
8398                .unwrap_or_else(|poisoned| poisoned.into_inner())
8399                .mark_not_captured("stderr pipe was not available on spawn");
8400            warn!(
8401                module_id = %spec.module_id,
8402                "spawned child exposed no stderr pipe; tail will be unavailable"
8403            );
8404            None
8405        }
8406    };
8407
8408    Ok(SupervisedChild {
8409        child,
8410        #[cfg(windows)]
8411        nonce_delivery,
8412        protocol: spec.protocol,
8413        #[cfg(target_os = "linux")]
8414        module_id: cgroup_name,
8415        #[cfg(target_os = "linux")]
8416        cgroup_placement: cgroup_placement.cloned(),
8417        #[cfg(windows)]
8418        job,
8419        stdout_pump,
8420        stderr_pump,
8421        stderr_ring: Arc::clone(ring),
8422        spawned_at_ms,
8423        spawned_from,
8424        spawned_file_identity,
8425        process_start_time,
8426        process_identity,
8427        pid,
8428        roster_guard: Some(roster_guard),
8429        #[cfg(target_os = "macos")]
8430        privacy_exec,
8431        #[cfg(target_os = "macos")]
8432        report_ready: Arc::new(OnceLock::new()),
8433        spawn_failure: None,
8434    })
8435}
8436
8437#[cfg(target_os = "linux")]
8438pub(crate) fn kill_module_cgroup(placement: Option<&subc_cgroup::Placement>, module_id: &str) {
8439    use subc_cgroup::KillOutcome;
8440    match subc_cgroup::kill_module(placement, module_id) {
8441        KillOutcome::Killed => {}
8442        KillOutcome::NotPlaced | KillOutcome::Unsupported => {
8443            debug!(
8444                module_id,
8445                "cgroup tree kill unavailable; using direct-child kill"
8446            );
8447        }
8448        KillOutcome::IoError { path, error } => {
8449            warn!(module_id, path = %path.display(), %error, "cgroup tree kill failed; using direct-child kill");
8450        }
8451    }
8452}
8453
8454/// Contain a freshly spawned Windows child and start it.
8455///
8456/// Steps 2 and 3 of the suspended-create contract: the job is created and the
8457/// child assigned **while it is still suspended** (step 1 is
8458/// `suspend_on_create_async` at the spawn site), then the child is resumed.
8459///
8460/// A child that is never resumed hangs forever holding a pid, so a resume
8461/// failure kills the child and fails the spawn rather than returning a
8462/// `SupervisedChild` that can never run.
8463///
8464/// An assignment failure is NOT fatal: an uncontained module behaves exactly as
8465/// it did before this existed, whereas refusing to start one would be a new
8466/// outage. It is logged at warn because it means a helper process could leak.
8467#[cfg(windows)]
8468fn contain_spawned_child(
8469    child: &Child,
8470    spec: &ModuleSpec,
8471) -> Result<Option<subc_jobobject::JobObject>, SuperviseError> {
8472    let module_id = spec.module_id.as_str();
8473    let Some(pid) = child.id() else {
8474        // The child exited between spawn and here. Its tree, if it made one,
8475        // needs no containment: nothing is left to contain.
8476        warn!(
8477            module_id,
8478            "spawned child had already exited before containment; no job object attached"
8479        );
8480        return Ok(None);
8481    };
8482
8483    let job = match subc_jobobject::JobObject::new() {
8484        Ok(job) => job,
8485        Err(source) => {
8486            warn!(
8487                module_id,
8488                error = %source,
8489                "could not create a job object; this module's helper processes will not be \
8490                 reaped on teardown"
8491            );
8492            // Resume regardless: leaving the child suspended would turn a
8493            // containment gap into a hung module.
8494            resume_suspended_child(pid, spec)?;
8495            return Ok(None);
8496        }
8497    };
8498
8499    if let Err(source) = job.assign(child) {
8500        warn!(
8501            module_id,
8502            error = %source,
8503            "could not assign the child to its job object; this module's helper processes \
8504             will not be reaped on teardown"
8505        );
8506        resume_suspended_child(pid, spec)?;
8507        return Ok(None);
8508    }
8509
8510    resume_suspended_child(pid, spec)?;
8511    Ok(Some(job))
8512}
8513
8514/// Resume a suspended child, killing it if it cannot be started.
8515///
8516/// A suspended process holds a pid and does nothing, so there is no useful
8517/// state to return: the caller gets an error and the spawn fails.
8518#[cfg(windows)]
8519fn resume_suspended_child(pid: u32, spec: &ModuleSpec) -> Result<(), SuperviseError> {
8520    if let Err(source) = subc_jobobject::resume_main_thread(pid) {
8521        // Kill it here rather than leaving a suspended process for the caller
8522        // to notice; `kill_on_drop` would eventually do this, but the module
8523        // would have been reported as running in between.
8524        let _ = std::process::Command::new("taskkill.exe")
8525            .args(["/PID", &pid.to_string(), "/T", "/F"])
8526            .stdin(Stdio::null())
8527            .stdout(Stdio::null())
8528            .stderr(Stdio::null())
8529            .status();
8530        return Err(SuperviseError::Spawn {
8531            program: spec.program.clone(),
8532            source,
8533            cgroup_path: None,
8534        });
8535    }
8536    Ok(())
8537}
8538
8539#[cfg(target_os = "linux")]
8540fn remove_module_cgroup(placement: &subc_cgroup::Placement, module_id: &str) {
8541    match placement.remove_module(module_id) {
8542        Ok(()) => debug!(module_id, "removed module cgroup after process exit"),
8543        Err(error) => warn!(
8544            module_id,
8545            error = %error,
8546            "could not remove module cgroup after process exit; continuing teardown"
8547        ),
8548    }
8549}
8550
8551#[cfg(target_os = "linux")]
8552async fn cleanup_reaped_cgroup(placement: &subc_cgroup::Placement, module_id: &str) {
8553    // Reaping the direct child is not proof its descendants exited. End the
8554    // residual tree and wait for the kernel's population fact before rmdir;
8555    // otherwise a successful parent wait leaks a directory on each restart.
8556    if matches!(
8557        subc_cgroup::kill_module(Some(placement), module_id),
8558        subc_cgroup::KillOutcome::Killed
8559    ) {
8560        if let Ok(path) = placement.module_path(module_id) {
8561            while std::fs::read_to_string(path.join("cgroup.events"))
8562                .ok()
8563                .is_some_and(|events| events.lines().any(|line| line == "populated 1"))
8564            {
8565                sleep(Duration::from_millis(1)).await;
8566            }
8567        }
8568    }
8569    remove_module_cgroup(placement, module_id);
8570}
8571
8572#[cfg(target_os = "linux")]
8573fn apply_cgroup_placement(
8574    command: &mut Command,
8575    spec: &ModuleSpec,
8576    path: &std::path::Path,
8577) -> Result<(), SuperviseError> {
8578    subc_cgroup::apply(command, path).map_err(|source| SuperviseError::Cgroup {
8579        module_id: spec.module_id.clone(),
8580        source,
8581    })
8582}
8583
8584fn capture_retention(spec: &ModuleSpec) -> Retention {
8585    let defaults = Retention::default();
8586    let value = |name: &str| {
8587        spec.env
8588            .iter()
8589            .rev()
8590            .find_map(|(key, value)| (key == name).then_some(value.as_str()))
8591    };
8592    Retention {
8593        max_file_mb: value(CAPTURE_MAX_FILE_MB_ENV)
8594            .and_then(|value| value.parse().ok())
8595            .unwrap_or(defaults.max_file_mb),
8596        keep: value(CAPTURE_KEEP_ENV)
8597            .and_then(|value| value.parse().ok())
8598            .unwrap_or(defaults.keep),
8599        max_age_days: value(CAPTURE_MAX_AGE_DAYS_ENV)
8600            .and_then(|value| value.parse().ok())
8601            .unwrap_or(defaults.max_age_days),
8602    }
8603}
8604
8605/// A fresh 256-bit CSPRNG launch nonce, lowercase hex. Used to bind a reserved
8606/// module's registration to the exact process the supervisor spawned.
8607fn generate_launch_nonce() -> Result<String, SuperviseError> {
8608    let mut bytes = [0u8; 32];
8609    getrandom::getrandom(&mut bytes).map_err(|source| SuperviseError::LaunchNonce {
8610        reason: source.to_string(),
8611    })?;
8612    let mut hex = String::with_capacity(64);
8613    for b in bytes {
8614        use std::fmt::Write;
8615        let _ = write!(hex, "{b:02x}");
8616    }
8617    Ok(hex)
8618}
8619
8620/// Constant-time byte comparison so a reserved-nonce mismatch leaks no timing
8621/// signal about how many leading bytes matched.
8622fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
8623    if a.len() != b.len() {
8624        return false;
8625    }
8626    let mut diff = 0u8;
8627    for (x, y) in a.iter().zip(b.iter()) {
8628        diff |= x ^ y;
8629    }
8630    diff == 0
8631}
8632
8633/// The kernel's image after an acknowledged exec, shared by ordinary launches
8634/// and privacy trampolines. PATH and shebang interpretation are kernel facts,
8635/// not identities inferred from a configured pathname.
8636#[cfg(not(windows))]
8637fn observe_spawned_image(pid: u32) -> Option<subc_os::Observation> {
8638    subc_os::Process::open(pid)
8639        .ok()
8640        .flatten()
8641        .and_then(|process| process.observe())
8642}
8643
8644/// Resolve once and spawn that exact path, so PATH lookup cannot choose a
8645/// different executable after the no-delete capture was taken.
8646#[cfg(windows)]
8647fn windows_spawn_capture(spec: &ModuleSpec) -> io::Result<subc_os::ExecutableCapture> {
8648    let mut program = spec.program.clone();
8649    if program.is_absolute() || program.components().count() > 1 {
8650        // An explicit path can name a PE image with no extension. Try that
8651        // exact file first, as Command does, before falling back to .exe.
8652        match subc_os::ExecutableCapture::open(&program) {
8653            Ok(capture) => return Ok(capture),
8654            Err(error)
8655                if error.kind() == io::ErrorKind::NotFound && program.extension().is_none() => {}
8656            Err(error) => return Err(error),
8657        }
8658        program.set_extension("exe");
8659        return subc_os::ExecutableCapture::open(&program);
8660    }
8661    if program.extension().is_none() {
8662        program.set_extension("exe");
8663    }
8664    let custom_path = spec
8665        .env
8666        .iter()
8667        .rev()
8668        .find(|(key, _)| key.eq_ignore_ascii_case("PATH"));
8669    let mut directories = Vec::new();
8670    // These are Windows' default executable search locations. A configured
8671    // PATH still overrides the inherited PATH, not the system directories.
8672    if let Some(parent) = std::env::current_exe()?.parent() {
8673        directories.push(parent.to_path_buf());
8674    }
8675    directories.push(std::env::current_dir()?);
8676    if let Some(windows) = std::env::var_os("SystemRoot") {
8677        let windows = PathBuf::from(windows);
8678        directories.push(windows.join("System32"));
8679        directories.push(windows.clone());
8680    }
8681    let path = custom_path
8682        .map(|(_, value)| std::ffi::OsString::from(value))
8683        .or_else(|| std::env::var_os("PATH"));
8684    if let Some(path) = path {
8685        directories.extend(std::env::split_paths(&path));
8686    }
8687    for directory in directories {
8688        match subc_os::ExecutableCapture::open(&directory.join(&program)) {
8689            Ok(capture) => return Ok(capture),
8690            Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
8691            Err(error) => return Err(error),
8692        }
8693    }
8694    Err(io::Error::new(
8695        io::ErrorKind::NotFound,
8696        "resolved executable not found",
8697    ))
8698}
8699
8700fn spawn_and_mark_running(
8701    spec: &ModuleSpec,
8702    runtime: &SupervisorRuntimeConfig,
8703    snapshot: &SharedSnapshot,
8704) -> Result<SupervisedChild, SuperviseError> {
8705    let activity_spawn = runtime.begin_activity_spawn(&spec.module_id);
8706    let child = spawn_child(
8707        spec,
8708        runtime.connection_file_path.as_deref(),
8709        runtime.supervisor_handle.as_ref(),
8710        &runtime.stderr_ring,
8711        runtime.capture_logs_dir.as_deref(),
8712        &runtime.child_roster,
8713        #[cfg(target_os = "linux")]
8714        runtime.cgroup_placement.as_ref(),
8715    )?;
8716    set_running(snapshot, &child, &spec.module_id, &runtime.spawn_events)?;
8717    if let Some(spawn) = activity_spawn {
8718        spawn.complete(snapshot);
8719    }
8720    Ok(child)
8721}
8722
8723enum RegistrationWaitOutcome {
8724    Registered,
8725    Exited(ExitReport),
8726    TimedOut,
8727}
8728
8729struct ReloadRegistrationFailure {
8730    exit_report: ExitReport,
8731    reason: String,
8732}
8733
8734#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8735enum BusyGaugeObservation {
8736    Quiescent,
8737    Busy,
8738    Omitted,
8739}
8740
8741fn busy_gauge_observation(metrics: Option<&Value>, gauges: &[String]) -> BusyGaugeObservation {
8742    let Some(metrics) = metrics.and_then(Value::as_object) else {
8743        return BusyGaugeObservation::Omitted;
8744    };
8745    let mut sum = 0u128;
8746    for gauge in gauges {
8747        let Some(value) = metrics.get(gauge) else {
8748            return BusyGaugeObservation::Omitted;
8749        };
8750        let Some(value) = value.as_u64() else {
8751            return BusyGaugeObservation::Busy;
8752        };
8753        sum = sum.saturating_add(u128::from(value));
8754    }
8755    if sum == 0 {
8756        BusyGaugeObservation::Quiescent
8757    } else {
8758        BusyGaugeObservation::Busy
8759    }
8760}
8761
8762fn declared_busy_gauges(
8763    registry: &Registry,
8764    module_id: &str,
8765) -> Result<Vec<String>, SuperviseError> {
8766    busy_gauges_of(
8767        registry
8768            .get_module(module_id)
8769            .map_err(SuperviseError::Registry)?,
8770    )
8771}
8772
8773/// [`declared_busy_gauges`] for the registration a connection holds, in any
8774/// slot: after cutover the incumbent is no longer the id's active
8775/// registration, and its own manifest is the one that names its gauges.
8776fn declared_busy_gauges_for_connection(
8777    registry: &Registry,
8778    connection_id: ConnectionId,
8779) -> Result<Vec<String>, SuperviseError> {
8780    busy_gauges_of(
8781        registry
8782            .get_module_by_connection(connection_id)
8783            .map_err(SuperviseError::Registry)?,
8784    )
8785}
8786
8787fn busy_gauges_of(
8788    registration: Option<crate::registry::ModuleRegistration>,
8789) -> Result<Vec<String>, SuperviseError> {
8790    let Some(registration) = registration else {
8791        return Ok(Vec::new());
8792    };
8793    let Some(self_signals) = registration.manifest.self_signals else {
8794        return Ok(Vec::new());
8795    };
8796
8797    let mut gauges = Vec::new();
8798    for declaration in self_signals {
8799        if declaration.kind != SelfSignalKind::Busy {
8800            continue;
8801        }
8802        match declaration.anchored_to {
8803            SignalAnchor::HealthGauges { gauges: declared } if !declared.is_empty() => {
8804                gauges.extend(declared)
8805            }
8806            _ => {
8807                // An invalid Busy anchor is fail-safe: the empty name cannot be
8808                // present in a conforming health report, so this drain stays busy.
8809                gauges.push(String::new());
8810            }
8811        }
8812    }
8813    Ok(gauges)
8814}
8815
8816/// Wait for `endpoint` to have nothing in flight and, when the module declares
8817/// busy gauges, for a health probe to report them quiet. The probe is addressed
8818/// by `scope`: a swap's superseded incumbent must be asked about its own
8819/// gauges, and by module id the probe would reach the promoted candidate.
8820async fn wait_for_forwarding_quiescence(
8821    forwarding: &ForwardingTable,
8822    module_id: &str,
8823    runtime: &SupervisorRuntimeConfig,
8824    endpoint: crate::ModuleEndpointId,
8825    deadline: Instant,
8826    busy_gauges: &[String],
8827    scope: DrainScope,
8828) -> Result<bool, SuperviseError> {
8829    let mut gauges_quiescent = busy_gauges.is_empty();
8830    let mut next_probe_at = Instant::now();
8831    let mut omission_counted = false;
8832
8833    loop {
8834        let now = Instant::now();
8835        if !busy_gauges.is_empty() && now >= next_probe_at && now < deadline {
8836            let report = match scope {
8837                DrainScope::Active => probe_module_health(module_id, runtime, Some(deadline)).await,
8838                DrainScope::Endpoint(endpoint) => {
8839                    probe_endpoint_health(endpoint, runtime, Some(deadline)).await
8840                }
8841            };
8842            gauges_quiescent = match report {
8843                Ok(report) => match busy_gauge_observation(report.metrics.as_ref(), busy_gauges) {
8844                    BusyGaugeObservation::Quiescent => true,
8845                    BusyGaugeObservation::Busy => false,
8846                    BusyGaugeObservation::Omitted => {
8847                        if !omission_counted {
8848                            forwarding
8849                                .counters()
8850                                .increment_drains_with_undeclared_gauge();
8851                            omission_counted = true;
8852                        }
8853                        false
8854                    }
8855                },
8856                Err(err) => {
8857                    warn!(
8858                        module_id,
8859                        error = %err,
8860                        "drain health.check did not produce declared busy gauges; treating module as busy"
8861                    );
8862                    false
8863                }
8864            };
8865            next_probe_at = Instant::now() + runtime.health.cadence.max(REGISTRY_RELEASE_POLL);
8866        }
8867
8868        let in_flight = forwarding
8869            .endpoint_in_flight_count(endpoint)
8870            .map_err(SuperviseError::Forwarding)?;
8871        if in_flight == 0 && gauges_quiescent {
8872            return Ok(true);
8873        }
8874
8875        let now = Instant::now();
8876        if now >= deadline {
8877            return Ok(false);
8878        }
8879        let mut wait = deadline
8880            .saturating_duration_since(now)
8881            .min(REGISTRY_RELEASE_POLL);
8882        if !busy_gauges.is_empty() {
8883            wait = wait.min(next_probe_at.saturating_duration_since(now));
8884        }
8885        sleep(wait).await;
8886    }
8887}
8888
8889/// The `route.closed` `drained` value implied by a quiescence-wait outcome.
8890///
8891/// `Ok` is always honest and passed straight through -- the wait actually measured
8892/// in-flight state. `Err` means the wait produced no measurement at all (the
8893/// forwarding table's lock was poisoned), so `false` is reported as the one honest
8894/// constant: the drain did not complete. Never recomputed from route state, never a
8895/// third "unknown" value -- the caller must still send a well-formed `route.closed`.
8896fn drained_after_quiescence_wait(wait_result: &Result<bool, SuperviseError>) -> bool {
8897    match wait_result {
8898        Ok(drained) => *drained,
8899        Err(_) => false,
8900    }
8901}
8902
8903fn send_route_goodbyes(forwarding: &ForwardingTable, released_routes: Vec<GoodbyeTarget>) {
8904    for released in released_routes {
8905        let frame = match Frame::build_with_version(
8906            released.negotiated_ver,
8907            FrameType::Goodbye,
8908            control_flags(),
8909            released.channel,
8910            released.epoch,
8911            0,
8912            Vec::new(),
8913        ) {
8914            Ok(frame) => frame,
8915            Err(err) => {
8916                warn!(
8917                    route_channel = released.channel,
8918                    error = %err,
8919                    "failed to build supervisor drain route GOODBYE frame"
8920                );
8921                continue;
8922            }
8923        };
8924        if !released.close_on_delivery_failure() {
8925            crate::forwarding::send_module_route_goodbye(
8926                &forwarding.counters(),
8927                &released.sink,
8928                frame,
8929                released.module_id.as_deref(),
8930                "supervisor drain",
8931            );
8932            continue;
8933        }
8934        if let Err(err) = released.sink.try_send(frame) {
8935            warn!(
8936                target_connection_id = released.connection_id.get(),
8937                route_channel = released.channel,
8938                error = %err,
8939                "supervisor drain route GOODBYE was not delivered to client; closing target connection"
8940            );
8941            let _ = forwarding.escalate_client_delivery_failure(
8942                released.connection_id,
8943                released.channel,
8944                released.epoch,
8945                CloseReason::new(
8946                    "route_goodbye_delivery_failed",
8947                    format!(
8948                        "failed to enqueue supervisor drain route GOODBYE for channel {}: {err}",
8949                        released.channel
8950                    ),
8951                ),
8952                crate::forwarding::UndeliveredFrame {
8953                    module_id: released.module_id.as_deref(),
8954                    sink: &released.sink,
8955                },
8956            );
8957        }
8958    }
8959}
8960
8961fn send_module_draining(
8962    module_id: &str,
8963    reason: RouteCloseReason,
8964    deadline_ms: u64,
8965    target: &ModuleDrainTarget,
8966) {
8967    let body = match serde_json::to_vec(&ModuleControlCommand::Draining {
8968        reason,
8969        deadline_ms,
8970    }) {
8971        Ok(body) => body,
8972        Err(err) => {
8973            warn!(
8974                module_id,
8975                error = %err,
8976                "failed to encode module draining command"
8977            );
8978            return;
8979        }
8980    };
8981    let frame = match Frame::build_with_version(
8982        target.negotiated_ver,
8983        FrameType::Push,
8984        control_flags(),
8985        0,
8986        0,
8987        0,
8988        body,
8989    ) {
8990        Ok(frame) => frame,
8991        Err(err) => {
8992            warn!(
8993                module_id,
8994                error = %err,
8995                "failed to build module draining command frame"
8996            );
8997            return;
8998        }
8999    };
9000    if let Err(err) = target.sink.try_send(frame) {
9001        warn!(
9002            module_id,
9003            target_connection_id = target.endpoint.connection_id.get(),
9004            error = %err,
9005            "module draining command was not delivered to peer"
9006        );
9007    }
9008}
9009
9010/// The channel-0 GOODBYE that tells a module its stop is planned.
9011fn module_goodbye_frame(module_id: &str, negotiated_ver: u8) -> Option<Frame> {
9012    match Frame::build_with_version(
9013        negotiated_ver,
9014        FrameType::Goodbye,
9015        control_flags(),
9016        0,
9017        0,
9018        0,
9019        Vec::new(),
9020    ) {
9021        Ok(frame) => Some(frame),
9022        Err(err) => {
9023            warn!(
9024                module_id,
9025                error = %err,
9026                "failed to build module GOODBYE frame"
9027            );
9028            None
9029        }
9030    }
9031}
9032
9033/// Send every registered module connection its module GOODBYE at daemon
9034/// shutdown, then request that connection's close.
9035///
9036/// A module tells a planned stop from a lost daemon by whether a GOODBYE came
9037/// before EOF, so the GOODBYE must reach the socket before the close. A close
9038/// request does not wait for the connection's queued frames: its writer gets a
9039/// bounded grace after the close, is aborted if it overruns it, and the daemon
9040/// process may exit before that grace ends. So with `wait_for_flush`, each
9041/// connection is closed only after its writer has acknowledged writing the
9042/// GOODBYE, or once a short shared budget runs out, so one module that is not
9043/// reading cannot hold up the others or the shutdown. Without it the GOODBYEs
9044/// are only queued, for a shutdown the operator has told to stop waiting.
9045/// A connection that is already gone is skipped.
9046#[cfg(unix)]
9047async fn send_module_goodbyes_for_daemon_shutdown(
9048    forwarding: &Arc<ForwardingTable>,
9049    reason: &CloseReason,
9050    wait_for_flush: bool,
9051) {
9052    const GOODBYE_BUDGET: Duration = Duration::from_millis(500);
9053    let targets = match forwarding.module_connections() {
9054        Ok(targets) => targets,
9055        Err(err) => {
9056            warn!(error = %err, "could not list module connections for shutdown GOODBYE");
9057            return;
9058        }
9059    };
9060    let deadline = Instant::now() + GOODBYE_BUDGET;
9061    let mut sends = tokio::task::JoinSet::new();
9062    for target in targets {
9063        let Some(frame) = module_goodbye_frame(&target.module_id, target.negotiated_ver) else {
9064            continue;
9065        };
9066        if !wait_for_flush {
9067            if let Err(err) = target.sink.try_send(frame) {
9068                debug!(
9069                    module_id = %target.module_id,
9070                    error = %err,
9071                    "shutdown module GOODBYE was not queued"
9072                );
9073            }
9074            continue;
9075        }
9076        let forwarding = Arc::clone(forwarding);
9077        let reason = reason.clone();
9078        sends.spawn(async move {
9079            match timeout_at(deadline, target.sink.send_flushed(frame)).await {
9080                Ok(Ok(())) => {}
9081                Ok(Err(err)) => debug!(
9082                    module_id = %target.module_id,
9083                    error = %err,
9084                    "module connection closed before its shutdown GOODBYE was written"
9085                ),
9086                Err(_) => warn!(
9087                    module_id = %target.module_id,
9088                    budget = ?GOODBYE_BUDGET,
9089                    "shutdown module GOODBYE was not written within its budget; closing anyway"
9090                ),
9091            }
9092            forwarding.request_connection_close(target.endpoint.connection_id, reason);
9093        });
9094    }
9095    // Every task ends by the shared deadline, so this wait is bounded too.
9096    while sends.join_next().await.is_some() {}
9097}
9098
9099fn send_module_goodbye(module_id: &str, forwarding: &ForwardingTable, target: &ModuleDrainTarget) {
9100    let Some(frame) = module_goodbye_frame(module_id, target.negotiated_ver) else {
9101        return;
9102    };
9103    if let Err(err) = target.sink.try_send(frame) {
9104        warn!(
9105            module_id,
9106            target_connection_id = target.endpoint.connection_id.get(),
9107            error = %err,
9108            "supervisor drain module GOODBYE was not delivered to peer; closing module connection"
9109        );
9110        forwarding.request_connection_close(
9111            target.endpoint.connection_id,
9112            CloseReason::new(
9113                "module_goodbye_delivery_failed",
9114                format!("failed to enqueue supervisor drain module GOODBYE for module '{module_id}': {err}"),
9115            ),
9116        );
9117    }
9118}
9119
9120#[derive(Clone, Copy)]
9121struct ForwardingDrainContext<'a> {
9122    spec: &'a ModuleSpec,
9123    runtime: &'a SupervisorRuntimeConfig,
9124    registry: &'a Registry,
9125    scope: DrainScope,
9126}
9127
9128/// Which process a forwarding drain addresses.
9129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9130enum DrainScope {
9131    /// Whatever endpoint is active for the module id: every plain stop,
9132    /// restart and reload. Also moves the module's state to `Draining`.
9133    Active,
9134    /// One specific endpoint: a swap's incumbent after cutover. Draining it by
9135    /// module id would resolve to the promoted candidate and leave neither
9136    /// process routable. The module's state is left alone, since the promoted
9137    /// candidate is what it describes and that process is running.
9138    Endpoint(crate::ModuleEndpointId),
9139}
9140
9141/// Whether a child being drained has already been asked to stop by the time
9142/// its drain wait starts.
9143///
9144/// The drain wait is the same budget whatever this says. What it decides is
9145/// whether the supervisor must ask by signal before that wait begins: a child
9146/// that nobody asked will sit out the whole budget and then be SIGKILLed,
9147/// healthy or not.
9148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9149enum StopNotice {
9150    /// The module was sent `module.draining` and a module GOODBYE over its own
9151    /// registered connection, and stops itself.
9152    SentOverConnection,
9153    /// The forwarding drain found no registered connection for the module: a
9154    /// subc child spawned moments ago that has not sent HELLO yet, or a
9155    /// `protocol: "none"` child, which never registers.
9156    NoConnection,
9157    /// This path sends nothing over the module's connection: the supervisor has
9158    /// no forwarding table, or the caller stops the child without a forwarding
9159    /// drain.
9160    NotSent,
9161}
9162
9163async fn begin_forwarding_drain(
9164    spec: &ModuleSpec,
9165    runtime: &SupervisorRuntimeConfig,
9166    registry: &Registry,
9167    snapshot: &SharedSnapshot,
9168    enabled: Option<bool>,
9169    reason: RouteCloseReason,
9170) -> Result<StopNotice, SuperviseError> {
9171    let Some(forwarding) = runtime.forwarding.as_ref() else {
9172        return Err(SuperviseError::ReloadUnavailable {
9173            module_id: spec.module_id.clone(),
9174            reason: "supervisor was not configured with a forwarding table".to_string(),
9175        });
9176    };
9177
9178    begin_forwarding_drain_with(
9179        forwarding,
9180        ForwardingDrainContext {
9181            spec,
9182            runtime,
9183            registry,
9184            scope: DrainScope::Active,
9185        },
9186        snapshot,
9187        enabled,
9188        reason,
9189        runtime.drain_timeout,
9190    )
9191    .await
9192}
9193
9194async fn begin_forwarding_drain_if_configured(
9195    spec: &ModuleSpec,
9196    runtime: &SupervisorRuntimeConfig,
9197    registry: &Registry,
9198    snapshot: &SharedSnapshot,
9199    enabled: Option<bool>,
9200    reason: RouteCloseReason,
9201) -> Result<StopNotice, SuperviseError> {
9202    begin_forwarding_drain_with_timeout(
9203        spec,
9204        runtime,
9205        registry,
9206        snapshot,
9207        enabled,
9208        reason,
9209        runtime.drain_timeout,
9210    )
9211    .await
9212}
9213
9214/// Like [`begin_forwarding_drain_if_configured`] but with an explicit drain
9215/// budget, for paths where the operator overrides the module's configured one
9216/// (`supervisor.restart{drain_timeout_ms}`).
9217async fn begin_forwarding_drain_with_timeout(
9218    spec: &ModuleSpec,
9219    runtime: &SupervisorRuntimeConfig,
9220    registry: &Registry,
9221    snapshot: &SharedSnapshot,
9222    enabled: Option<bool>,
9223    reason: RouteCloseReason,
9224    drain_timeout: Duration,
9225) -> Result<StopNotice, SuperviseError> {
9226    let Some(forwarding) = runtime.forwarding.as_ref() else {
9227        return Ok(StopNotice::NotSent);
9228    };
9229
9230    begin_forwarding_drain_with(
9231        forwarding,
9232        ForwardingDrainContext {
9233            spec,
9234            runtime,
9235            registry,
9236            scope: DrainScope::Active,
9237        },
9238        snapshot,
9239        enabled,
9240        reason,
9241        drain_timeout,
9242    )
9243    .await
9244}
9245
9246async fn begin_forwarding_drain_with(
9247    forwarding: &ForwardingTable,
9248    context: ForwardingDrainContext<'_>,
9249    snapshot: &SharedSnapshot,
9250    enabled: Option<bool>,
9251    reason: RouteCloseReason,
9252    drain_timeout: Duration,
9253) -> Result<StopNotice, SuperviseError> {
9254    let ForwardingDrainContext {
9255        spec,
9256        runtime,
9257        registry,
9258        scope,
9259    } = context;
9260    debug_assert_ne!(reason, RouteCloseReason::Crash);
9261    let terminal = matches!(reason, RouteCloseReason::Disable);
9262    let drain_started_at = Instant::now();
9263    let drain_deadline = drain_started_at + drain_timeout;
9264    let deadline_ms =
9265        unix_ms_now().saturating_add(u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX));
9266    let busy_gauges = match scope {
9267        DrainScope::Active => declared_busy_gauges(registry, &spec.module_id)?,
9268        DrainScope::Endpoint(endpoint) => {
9269            declared_busy_gauges_for_connection(registry, endpoint.connection_id)?
9270        }
9271    };
9272
9273    // Admission gate first: route.open/commit and route REQUEST admission are closed
9274    // before the first quiescence check, so the outstanding count can only fall.
9275    let gate_started = Instant::now();
9276    let drain_target = match scope {
9277        DrainScope::Active => forwarding.begin_module_drain(&spec.module_id, reason),
9278        DrainScope::Endpoint(endpoint) => forwarding.begin_endpoint_drain(endpoint, reason),
9279    }
9280    .map_err(SuperviseError::Forwarding)?;
9281    // The instant admission closed, and how long taking the forwarding write
9282    // lock to close it took. The timeout line reports only the quiescence
9283    // wait, so without this a drain that started late looked like one that
9284    // started on time.
9285    info!(
9286        module_id = %spec.module_id,
9287        ?reason,
9288        gate_ms = u64::try_from(gate_started.elapsed().as_millis()).unwrap_or(u64::MAX),
9289        connected = drain_target.is_some(),
9290        "module drain began; route admission closed"
9291    );
9292    if scope == DrainScope::Active {
9293        update_snapshot(snapshot, Some(&spec.module_id), |state| {
9294            state.state = ModuleState::Draining;
9295            state.draining_to_replace =
9296                matches!(reason, RouteCloseReason::Restart | RouteCloseReason::Reload);
9297            if let Some(enabled) = enabled {
9298                state.enabled = enabled;
9299            }
9300        })?;
9301    }
9302
9303    let Some(target) = drain_target.as_ref() else {
9304        // Nothing was sent: the module has no registered connection to carry
9305        // `module.draining` or a GOODBYE. The caller must not assume the child
9306        // was asked to stop.
9307        return Ok(StopNotice::NoConnection);
9308    };
9309    {
9310        send_module_draining(&spec.module_id, reason, deadline_ms, target);
9311        let routes = forwarding
9312            .endpoint_routes(target.endpoint)
9313            .map_err(SuperviseError::Forwarding)?;
9314        let routes_notified = routes.len();
9315        crate::control::send_route_control_pushes(
9316            forwarding,
9317            routes.clone(),
9318            ClientControlPush::RouteClosing {
9319                module_id: spec.module_id.clone(),
9320                channels: Vec::new(),
9321                reason,
9322            },
9323        );
9324        send_route_goodbyes(forwarding, target.abandoned_bindings.clone());
9325
9326        // `route.closing` was just sent above: from here on every return path,
9327        // including an early one, MUST send `route.closed` before propagating
9328        // anything else. A client holds `closing` as a promise that a verdict is
9329        // coming; leaving early without `closed` strands it waiting forever, since
9330        // `closing` carries no timeout of its own.
9331        let wait_result = wait_for_forwarding_quiescence(
9332            forwarding,
9333            &spec.module_id,
9334            runtime,
9335            target.endpoint,
9336            drain_deadline,
9337            &busy_gauges,
9338            scope,
9339        )
9340        .await;
9341        let drained = drained_after_quiescence_wait(&wait_result);
9342        if let Err(err) = &wait_result {
9343            error!(
9344                module_id = %spec.module_id,
9345                ?reason,
9346                error = %err,
9347                "forwarding quiescence wait failed after route.closing; forcing route.closed(drained: false) so the client is not left waiting on an unfulfilled promise"
9348            );
9349        } else if !drained {
9350            // Name what the drain waited on. Without it the line says only that
9351            // something did not settle, and "one wedged call" and "every
9352            // session's held stream" read the same; the first is a module bug,
9353            // the second is a module that should end its streams on
9354            // module.draining. Read before teardown releases the routes.
9355            let holdouts = forwarding
9356                .endpoint_drain_holdouts(target.endpoint)
9357                .unwrap_or_default();
9358            warn!(
9359                module_id = %spec.module_id,
9360                waited = ?drain_timeout,
9361                ?reason,
9362                held_requests = holdouts.requests,
9363                held_routes = holdouts.routes,
9364                total_routes = holdouts.total_routes,
9365                top_connections = ?holdouts.top_connections,
9366                // `module_channel:corr`, so the module can find each held request
9367                // in its own log; capped, so `held_requests` is the full count.
9368                held = %holdouts
9369                    .held
9370                    .iter()
9371                    .map(|(channel, corr)| format!("{channel}:{corr}"))
9372                    .collect::<Vec<_>>()
9373                    .join(","),
9374                "route drain timed out before request quiescence; forcing teardown"
9375            );
9376        }
9377        crate::control::send_route_control_pushes(
9378            forwarding,
9379            routes,
9380            ClientControlPush::RouteClosed {
9381                module_id: spec.module_id.clone(),
9382                channels: Vec::new(),
9383                reason,
9384                drained,
9385                abandoned: target.abandoned_bindings.len() as u32,
9386                excluded_subscriptions: target.excluded_subscriptions,
9387                terminal: Some(terminal),
9388            },
9389        );
9390        wait_result?;
9391
9392        // `route.closed` has now been sent unconditionally above. From here the
9393        // remaining steps are cleanup (route + module GOODBYE) rather than a
9394        // promise the client is waiting on, but a lock-poisoned
9395        // `release_module_endpoint_routes` would otherwise skip the module
9396        // GOODBYE silently too -- send it before propagating the error.
9397        let released_routes = match forwarding.release_module_endpoint_routes(target.endpoint) {
9398            Ok(routes) => routes,
9399            Err(err) => {
9400                warn!(
9401                    module_id = %spec.module_id,
9402                    ?reason,
9403                    error = %err,
9404                    "failed to release module endpoint routes after route.closed; module GOODBYE will still be sent"
9405                );
9406                send_module_goodbye(&spec.module_id, forwarding, target);
9407                return Err(SuperviseError::Forwarding(err));
9408            }
9409        };
9410        let route_goodbye_count = released_routes.len();
9411        send_route_goodbyes(forwarding, released_routes);
9412        send_module_goodbye(&spec.module_id, forwarding, target);
9413
9414        // The drain's happy path was previously silent: every emission above is
9415        // best-effort with only its failure arm logged, so "were consumers told"
9416        // was unprovable from the daemon log (surfaced by a 30-minute consumer
9417        // hang where the open question was exactly whether teardown notice went
9418        // out). One summary line makes that class decidable in one grep.
9419        info!(
9420            module_id = %spec.module_id,
9421            ?reason,
9422            routes_notified,
9423            route_goodbyes = route_goodbye_count,
9424            abandoned_reservations = target.abandoned_bindings.len(),
9425            excluded_subscriptions = target.excluded_subscriptions,
9426            drained,
9427            "module drain complete; consumers notified via route.closing/route.closed pushes and per-route GOODBYE frames"
9428        );
9429    }
9430
9431    Ok(StopNotice::SentOverConnection)
9432}
9433
9434/// Wait for the freshly spawned child to take the ACTIVE slot for `module_id`,
9435/// the only slot a plain (non-swap) spawn can register into.
9436async fn wait_for_registration_after_reload(
9437    registry: &Registry,
9438    module_id: &str,
9439    snapshot: &SharedSnapshot,
9440    child: &mut SupervisedChild,
9441    wait: Duration,
9442) -> Result<RegistrationWaitOutcome, SuperviseError> {
9443    wait_for_slot_registration(
9444        registry,
9445        crate::registry::RegistrationSlot::Active(module_id),
9446        module_id,
9447        snapshot,
9448        child,
9449        wait,
9450    )
9451    .await
9452}
9453
9454/// Wait for `child` to register into `slot`, or to exit, or for `wait` to pass.
9455///
9456/// Keyed on the slot rather than the bare module id because during a swap the
9457/// id's active slot is already held by the incumbent: an id-keyed wait would
9458/// report the incumbent's registration as the candidate's and a candidate that
9459/// never registers would look registered. A swap candidate waits on
9460/// `crate::registry::RegistrationSlot::Candidate`.
9461async fn wait_for_slot_registration(
9462    registry: &Registry,
9463    slot: crate::registry::RegistrationSlot<'_>,
9464    module_id: &str,
9465    snapshot: &SharedSnapshot,
9466    child: &mut SupervisedChild,
9467    wait: Duration,
9468) -> Result<RegistrationWaitOutcome, SuperviseError> {
9469    let deadline = Instant::now() + wait;
9470    loop {
9471        if registry
9472            .registration(slot)
9473            .map_err(SuperviseError::Registry)?
9474            .is_some()
9475        {
9476            return Ok(RegistrationWaitOutcome::Registered);
9477        }
9478
9479        let now = Instant::now();
9480        if now >= deadline {
9481            return Ok(RegistrationWaitOutcome::TimedOut);
9482        }
9483        let remaining = deadline.saturating_duration_since(now);
9484        let poll = remaining.min(REGISTRY_RELEASE_POLL);
9485
9486        tokio::select! {
9487            wait_result = child.wait() => {
9488                let status = wait_result.map_err(|source| SuperviseError::Wait {
9489                    module_id: module_id.to_string(),
9490                    source,
9491                })?;
9492                return Ok(RegistrationWaitOutcome::Exited(classify_reaped_child_exit(
9493                    snapshot,
9494                    child,
9495                    &status,
9496                )));
9497            }
9498            _ = sleep(poll) => {}
9499        }
9500    }
9501}
9502
9503fn registration_failure_exit_report(mut exit_report: ExitReport) -> ExitReport {
9504    // A replacement process that exits before HELLO did not provide service, even
9505    // if it used status 0. Count it against the restart cap as a new-binary failure.
9506    if exit_report.kind != ExitKind::DeliberateSeverance {
9507        exit_report.kind = ExitKind::Crash;
9508    }
9509    exit_report
9510}
9511
9512async fn handle_reload_child_registration_failure(
9513    spec: &ModuleSpec,
9514    runtime: &SupervisorRuntimeConfig,
9515    registry: &Registry,
9516    process_liveness: &SupervisorProcessLiveness,
9517    snapshot: &SharedSnapshot,
9518    _child: &mut Option<SupervisedChild>,
9519    failure: ReloadRegistrationFailure,
9520) -> Result<(), SuperviseError> {
9521    let ReloadRegistrationFailure {
9522        exit_report,
9523        reason,
9524    } = failure;
9525    match on_child_exit(
9526        spec,
9527        runtime.restart_policy,
9528        registry,
9529        snapshot,
9530        &runtime.terminal_ring,
9531        &runtime.spawn_events,
9532        &runtime.child_roster,
9533        exit_report,
9534    )
9535    .await
9536    {
9537        NextAction::Stop {
9538            registration_released,
9539        } => {
9540            if registration_released {
9541                process_liveness.untrack_if_current(&spec.module_id, snapshot);
9542            }
9543        }
9544        NextAction::Restart { schedule } => {
9545            let delay = schedule.map_or(runtime.restart_policy.delay_for_restart(0), |schedule| {
9546                schedule.delay
9547            });
9548            if let Some(schedule) = schedule {
9549                log_crash_respawn(&spec.module_id, schedule);
9550            }
9551            schedule_respawn(
9552                runtime,
9553                snapshot,
9554                &spec.module_id,
9555                delay,
9556                RespawnKind::Spawn,
9557            )?;
9558        }
9559    }
9560    Err(SuperviseError::ReloadFailed {
9561        module_id: spec.module_id.clone(),
9562        reason,
9563    })
9564}
9565
9566async fn handle_reload_spawn_failure(
9567    spec: &ModuleSpec,
9568    runtime: &SupervisorRuntimeConfig,
9569    process_liveness: &SupervisorProcessLiveness,
9570    snapshot: &SharedSnapshot,
9571    _child: &mut Option<SupervisedChild>,
9572    reason: String,
9573) -> Result<(), SuperviseError> {
9574    let now = Instant::now();
9575    let mut schedule = None;
9576    update_snapshot(snapshot, Some(&spec.module_id), |state| {
9577        clear_current_process_facts(state);
9578        if state.enabled {
9579            schedule = state.next_crash_restart(&runtime.restart_policy, now);
9580            state.state = if schedule.is_some() {
9581                ModuleState::Restarting
9582            } else {
9583                ModuleState::Failed
9584            };
9585        } else {
9586            state.state = ModuleState::Disabled;
9587        }
9588    })?;
9589    if let Some(schedule) = schedule {
9590        schedule_respawn(
9591            runtime,
9592            snapshot,
9593            &spec.module_id,
9594            schedule.delay,
9595            RespawnKind::Spawn,
9596        )?;
9597    } else {
9598        process_liveness.untrack_if_current(&spec.module_id, snapshot);
9599    }
9600    Err(SuperviseError::ReloadFailed {
9601        module_id: spec.module_id.clone(),
9602        reason,
9603    })
9604}
9605
9606fn control_flags() -> Flags {
9607    Flags::new(false, Priority::Passive, false)
9608}
9609
9610#[allow(clippy::too_many_arguments)]
9611async fn drain_optional_child(
9612    module_id: &str,
9613    protocol: ModuleProtocol,
9614    stop_notice: StopNotice,
9615    registry: &Registry,
9616    forwarding: Option<&ForwardingTable>,
9617    snapshot: &SharedSnapshot,
9618    terminal_ring: &Arc<Mutex<TerminalRing>>,
9619    spawn_events: &SpawnEventFeed,
9620    child: &mut Option<SupervisedChild>,
9621    drain_timeout: Duration,
9622    final_state: ModuleState,
9623    enabled: Option<bool>,
9624) -> Result<(), SuperviseError> {
9625    if let Some(child) = child.take() {
9626        drain_child_to_state(
9627            module_id,
9628            protocol,
9629            stop_notice,
9630            registry,
9631            forwarding,
9632            snapshot,
9633            terminal_ring,
9634            spawn_events,
9635            child,
9636            drain_timeout,
9637            final_state,
9638            enabled,
9639        )
9640        .await
9641    } else {
9642        update_snapshot(snapshot, Some(module_id), |state| {
9643            state.state = final_state;
9644            if let Some(enabled) = enabled {
9645                state.enabled = enabled;
9646            }
9647            clear_current_process_facts(state);
9648        })?;
9649        release_dead_registration(registry, forwarding, snapshot, module_id).await
9650    }
9651}
9652
9653#[allow(clippy::too_many_arguments)]
9654async fn drain_child_to_state(
9655    module_id: &str,
9656    _protocol: ModuleProtocol,
9657    stop_notice: StopNotice,
9658    registry: &Registry,
9659    forwarding: Option<&ForwardingTable>,
9660    snapshot: &SharedSnapshot,
9661    terminal_ring: &Arc<Mutex<TerminalRing>>,
9662    spawn_events: &SpawnEventFeed,
9663    mut child: SupervisedChild,
9664    drain_timeout: Duration,
9665    final_state: ModuleState,
9666    enabled: Option<bool>,
9667) -> Result<(), SuperviseError> {
9668    let protocol = child.protocol;
9669    update_snapshot(snapshot, Some(module_id), |state| {
9670        state.state = ModuleState::Draining;
9671        state.draining_to_replace = final_state == ModuleState::Restarting;
9672        if let Some(enabled) = enabled {
9673            state.enabled = enabled;
9674        }
9675    })?;
9676
9677    // The wait below is the same budget in every case; what differs is
9678    // whether anything has ASKED the child to stop before it starts. Only a
9679    // forwarding drain that reached the module's registered connection has
9680    // (`module.draining`, then a module GOODBYE). Every other child was told
9681    // nothing: a `protocol: "none"` module, which never registers; a subc
9682    // module spawned moments ago that has not sent HELLO yet; or a stop that
9683    // runs no forwarding drain. Without a signal the budget is only a delay
9684    // in front of SIGKILL -- and the not-yet-registered child is the worst
9685    // case, because it registers into a module that is already draining,
9686    // is never told, and is killed while healthy.
9687    if stop_notice != StopNotice::SentOverConnection {
9688        if protocol == ModuleProtocol::Subc && stop_notice == StopNotice::NoConnection {
9689            info!(
9690                module_id,
9691                pid = child.pid,
9692                budget_ms = u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX),
9693                "module has no connection yet; requesting stop by signal"
9694            );
9695        }
9696        request_graceful_stop(module_id, &child);
9697    }
9698
9699    let exit_report = match timeout(drain_timeout, child.wait()).await {
9700        Ok(Ok(status)) => classify_reaped_child_exit(snapshot, &child, &status),
9701        Ok(Err(source)) => {
9702            fail_after_wait_error(
9703                module_id,
9704                snapshot,
9705                terminal_ring,
9706                spawn_events,
9707                format!("failed to wait during drain: {source}"),
9708            );
9709            return Err(SuperviseError::Wait {
9710                module_id: module_id.to_string(),
9711                source,
9712            });
9713        }
9714        Err(_) => {
9715            // Mirror the sibling arm above: state is already `Draining`, and an
9716            // error propagated from here would strand it there -- a state
9717            // `set_enabled(true)` cannot heal (`revive_terminal` matches only
9718            // `Failed | Stopped`), leaving an operator Restart as the only exit.
9719            // `Failed` before `?` keeps the module operator-visible and
9720            // revivable. Trigger is an ESRCH race (process exits between the
9721            // drain timeout firing and the kill) or a post-kill wait failure
9722            // (issue #34).
9723            //
9724            // Logged because the kill is otherwise visible only as signal 9 in
9725            // the terminal ring, and the budget it follows can be long enough
9726            // that consumers see a stretch of refusals with no stated cause.
9727            warn!(
9728                module_id,
9729                pid = child.pid,
9730                budget_ms = u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX),
9731                reason = ?final_state,
9732                ?stop_notice,
9733                "drain budget expired before the module exited; killing it"
9734            );
9735            child.start_kill().map_err(|source| {
9736                fail_after_wait_error(
9737                    module_id,
9738                    snapshot,
9739                    terminal_ring,
9740                    spawn_events,
9741                    format!("failed to kill during drain: {source}"),
9742                );
9743                SuperviseError::Kill {
9744                    module_id: module_id.to_string(),
9745                    source,
9746                }
9747            })?;
9748            let status = child.wait().await.map_err(|source| {
9749                fail_after_wait_error(
9750                    module_id,
9751                    snapshot,
9752                    terminal_ring,
9753                    spawn_events,
9754                    format!("failed to wait after drain kill: {source}"),
9755                );
9756                SuperviseError::Wait {
9757                    module_id: module_id.to_string(),
9758                    source,
9759                }
9760            })?;
9761            classify_reaped_child_exit(snapshot, &child, &status)
9762        }
9763    };
9764
9765    let detail = lock_snapshot(snapshot)?.drain_disposition_detail.take();
9766    publish_after_terminal_record(
9767        module_id,
9768        || {
9769            record_terminal_with_detail(
9770                module_id,
9771                terminal_ring,
9772                spawn_events,
9773                &exit_report,
9774                terminal_disposition(final_state),
9775                detail,
9776            )
9777        },
9778        || {
9779            update_snapshot(snapshot, Some(module_id), |state| {
9780                state.state = final_state;
9781                if let Some(enabled) = enabled {
9782                    state.enabled = enabled;
9783                }
9784                clear_current_process_facts(state);
9785                state.last_exit = Some(exit_report.clone());
9786                if exit_report.kind == ExitKind::DeliberateSeverance {
9787                    state.lifetime_restarts += 1;
9788                }
9789            })
9790        },
9791    )?;
9792    child.drain_stderr(module_id).await;
9793
9794    release_dead_registration(registry, forwarding, snapshot, module_id).await
9795}
9796
9797/// Ask a child that nothing else has asked to stop, by signal.
9798///
9799/// A registered subc module is asked over its own connection: the drain sends
9800/// `route.closing`/`route.closed` to its consumers, a GOODBYE per route, then a
9801/// module GOODBYE, and the module stops itself. A module that speaks no subc
9802/// wire receives none of that, and neither does a subc module that has not
9803/// registered yet, so for them the drain budget would be pure delay in front of
9804/// a SIGKILL -- and for a process with a store to flush (JetStream is the
9805/// reason `protocol: "none"` exists) a SIGKILL turns every ordinary teardown
9806/// into a recovery on the next start.
9807///
9808/// NEVER CALLED FOR A MODULE THAT WAS TOLD OVER ITS CONNECTION, and that is a
9809/// rule rather than an optimisation: that module's graceful stop is already
9810/// running by the time its child is drained, and a signal would race it.
9811///
9812/// Best-effort by construction. A child that has already exited is the ordinary
9813/// case rather than an error (the kill lands on a reaped or exiting pid), so a
9814/// failure is logged at debug and the wait-then-kill below still decides the
9815/// outcome.
9816#[cfg(unix)]
9817fn request_graceful_stop(module_id: &str, child: &SupervisedChild) {
9818    let Some(pid) = child
9819        .id()
9820        .and_then(|pid| i32::try_from(pid).ok())
9821        .and_then(rustix::process::Pid::from_raw)
9822    else {
9823        debug!(
9824            module_id,
9825            "no pid to signal for teardown; falling through to the drain wait"
9826        );
9827        return;
9828    };
9829    match rustix::process::kill_process(pid, rustix::process::Signal::TERM) {
9830        Ok(()) => debug!(
9831            module_id,
9832            "sent SIGTERM to a module nothing else asked to stop"
9833        ),
9834        Err(err) => debug!(
9835            module_id,
9836            error = %err,
9837            "SIGTERM to module failed; the drain wait and kill still apply"
9838        ),
9839    }
9840}
9841
9842/// Windows has no SIGTERM and no portable stand-in for one. The graceful stops
9843/// Windows does offer need cooperation this supervisor cannot assume: a console
9844/// control event requires sharing a console with the child, and `WM_CLOSE`
9845/// requires the child to pump a message loop. A supervised server process does
9846/// neither, so there is nothing to send and teardown is the wait followed by the
9847/// kill. Emulating a signal here would mean inventing a stop protocol, which is
9848/// the thing `protocol: "none"` exists to avoid.
9849#[cfg(not(unix))]
9850fn request_graceful_stop(module_id: &str, _child: &SupervisedChild) {
9851    debug!(
9852        module_id,
9853        "no graceful stop signal exists on this platform; teardown of a module nothing asked to stop waits, then kills"
9854    );
9855}
9856
9857fn terminal_disposition(final_state: ModuleState) -> TerminalDisposition {
9858    match final_state {
9859        ModuleState::Stopped => TerminalDisposition::Stopped,
9860        ModuleState::Disabled => TerminalDisposition::Disabled,
9861        ModuleState::Restarting => TerminalDisposition::Restarting,
9862        ModuleState::Failed => TerminalDisposition::Failed,
9863        ModuleState::Starting
9864        | ModuleState::Running
9865        | ModuleState::Unresponsive
9866        | ModuleState::Draining => {
9867            unreachable!("terminal exits only finish in terminal or restarting states")
9868        }
9869    }
9870}
9871
9872/// Release a reaped child's registration before allowing another spawn.
9873///
9874/// EOF is not a process-lifetime signal: an inherited socket can stay open
9875/// indefinitely, and serial frame dispatch can be waiting on egress instead of
9876/// reading EOF. After the normal release grace, request connection close (which
9877/// cancels both reads and dispatch), then allow one more release grace for the
9878/// connection guard's forwarding cleanup. Never evict a different connection.
9879async fn release_dead_registration(
9880    registry: &Registry,
9881    forwarding: Option<&ForwardingTable>,
9882    snapshot: &SharedSnapshot,
9883    module_id: &str,
9884) -> Result<(), SuperviseError> {
9885    let result = async {
9886        let registration = registry
9887            .get_module(module_id)
9888            .map_err(SuperviseError::Registry)?;
9889        match wait_for_registration_release(registry, module_id, REGISTRY_RELEASE_TIMEOUT).await {
9890            Ok(()) => return Ok(()),
9891            Err(SuperviseError::RegistrationStillActive { .. }) => {}
9892            Err(err) => return Err(err),
9893        }
9894        let pid = lock_snapshot(snapshot)?.reaped_pid;
9895        if let (Some(registration), Some(forwarding), Some(pid)) = (registration, forwarding, pid) {
9896            warn!(
9897                module_id,
9898                pid,
9899                connection_id = registration.connection_id.get(),
9900                "reaped module registration outlived release grace; closing dead connection"
9901            );
9902            forwarding.request_connection_close(
9903                registration.connection_id,
9904                CloseReason::new(
9905                    "supervised_process_reaped",
9906                    format!("module '{module_id}' pid {pid} exited"),
9907                ),
9908            );
9909            wait_for_slot_registration_release(
9910                registry,
9911                crate::registry::RegistrationSlot::Connection(registration.connection_id),
9912                REGISTRY_RELEASE_TIMEOUT,
9913            )
9914            .await?;
9915        }
9916        wait_for_registration_release(registry, module_id, Duration::ZERO).await
9917    }
9918    .await;
9919    if let Err(err) = &result {
9920        fail_snapshot(snapshot, Some(module_id), None);
9921        error!(module_id, error = %err, "registration release failed after child exit; module is failed and start can retry");
9922    }
9923    result
9924}
9925
9926/// Wait for the ACTIVE registration of `module_id` to go away, which is what a
9927/// plain stop or restart waits for before it spawns a replacement.
9928async fn wait_for_registration_release(
9929    registry: &Registry,
9930    module_id: &str,
9931    wait: Duration,
9932) -> Result<(), SuperviseError> {
9933    wait_for_slot_registration_release(
9934        registry,
9935        crate::registry::RegistrationSlot::Active(module_id),
9936        wait,
9937    )
9938    .await
9939}
9940
9941/// Wait for the registration in `slot` to go away.
9942///
9943/// Keyed on the slot rather than the bare module id because a successful swap
9944/// never empties the id's active slot (the promoted candidate is in it), so an
9945/// id-keyed wait for the incumbent's release would always time out. Draining a
9946/// swap's incumbent waits on `crate::registry::RegistrationSlot::Connection` with the
9947/// incumbent's connection instead.
9948async fn wait_for_slot_registration_release(
9949    registry: &Registry,
9950    slot: crate::registry::RegistrationSlot<'_>,
9951    wait: Duration,
9952) -> Result<(), SuperviseError> {
9953    let deadline = Instant::now() + wait;
9954    let mut release_events = registration_release_events().subscribe();
9955    let still_active = |registration: &crate::registry::ModuleRegistration| {
9956        SuperviseError::RegistrationStillActive {
9957            module_id: registration.manifest.module_id.clone(),
9958            waited: wait,
9959        }
9960    };
9961    loop {
9962        let _observed_generation = *release_events.borrow_and_update();
9963        let Some(registration) = registry
9964            .registration(slot)
9965            .map_err(SuperviseError::Registry)?
9966        else {
9967            return Ok(());
9968        };
9969
9970        let now = Instant::now();
9971        if now >= deadline {
9972            return Err(still_active(&registration));
9973        }
9974
9975        let remaining = deadline.saturating_duration_since(now);
9976        match timeout(remaining, release_events.changed()).await {
9977            Ok(Ok(())) | Ok(Err(_)) => {}
9978            Err(_) => return Err(still_active(&registration)),
9979        }
9980    }
9981}
9982
9983#[cfg(test)]
9984mod slot_registration_wait_tests {
9985    use super::*;
9986    use crate::registry::{ConnectionId, RegistrationSlot};
9987    use subc_protocol::manifest::ModuleManifest;
9988
9989    #[tokio::test]
9990    async fn enable_release_failure_is_failed_and_a_second_enable_retries() {
9991        let registry = Arc::new(Registry::default());
9992        let supervisor = Supervisor::new_for_test(Arc::clone(&registry), RestartPolicy::default());
9993        let runtime = supervisor.runtime_config();
9994        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::disabled()));
9995        let spec = ModuleSpec {
9996            module_id: "enable-stale-registration".to_string(),
9997            program: PathBuf::from("/missing/enable-retry-test"),
9998            args: Vec::new(),
9999            env: Vec::new(),
10000            reserved: false,
10001            reserved_prefixes: Vec::new(),
10002            protocol: ModuleProtocol::Subc,
10003            overlap: Default::default(),
10004        };
10005        let connection = ConnectionId::new(90);
10006        registry
10007            .register_with_control_ops(
10008                ModuleManifest::builder(&spec.module_id, "0.1.0").build(),
10009                1,
10010                connection,
10011                Vec::new(),
10012            )
10013            .unwrap();
10014        let mut child = None;
10015        let err = set_child_enabled(
10016            &spec,
10017            &runtime,
10018            &registry,
10019            &supervisor.process_liveness,
10020            &snapshot,
10021            &mut child,
10022            true,
10023        )
10024        .await
10025        .unwrap_err();
10026        assert!(matches!(
10027            err,
10028            SuperviseError::RegistrationStillActive { .. }
10029        ));
10030        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
10031        assert!(child.is_none());
10032        registry.deregister_connection(connection).unwrap();
10033        let err = set_child_enabled(
10034            &spec,
10035            &runtime,
10036            &registry,
10037            &supervisor.process_liveness,
10038            &snapshot,
10039            &mut child,
10040            true,
10041        )
10042        .await
10043        .unwrap_err();
10044        assert!(
10045            matches!(err, SuperviseError::Spawn { .. }),
10046            "second enable must attempt a spawn: {err}"
10047        );
10048        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
10049    }
10050
10051    const INCUMBENT: u64 = 1;
10052    const CANDIDATE: u64 = 2;
10053
10054    fn swapped_registry() -> Arc<Registry> {
10055        let registry = Arc::new(Registry::default());
10056        let manifest = ModuleManifest::builder("m", "0.1.0").build();
10057        registry
10058            .register_with_control_ops(
10059                manifest.clone(),
10060                1,
10061                ConnectionId::new(INCUMBENT),
10062                Vec::new(),
10063            )
10064            .unwrap();
10065        registry
10066            .register_candidate_with_control_ops(
10067                manifest,
10068                1,
10069                ConnectionId::new(CANDIDATE),
10070                Vec::new(),
10071            )
10072            .unwrap();
10073        registry
10074    }
10075
10076    /// After a promotion the id's active slot is held by the new process, so an
10077    /// id-keyed wait for the incumbent's release can never succeed; the
10078    /// connection-keyed wait completes as soon as the incumbent deregisters.
10079    #[tokio::test]
10080    async fn incumbent_release_is_awaited_by_connection_not_by_module_id() {
10081        let registry = swapped_registry();
10082        registry.promote_candidate("m").unwrap().unwrap();
10083
10084        assert!(matches!(
10085            wait_for_registration_release(&registry, "m", Duration::from_millis(50)).await,
10086            Err(SuperviseError::RegistrationStillActive { .. })
10087        ));
10088
10089        // Still held while the incumbent's connection has not deregistered.
10090        assert!(matches!(
10091            wait_for_slot_registration_release(
10092                &registry,
10093                RegistrationSlot::Connection(ConnectionId::new(INCUMBENT)),
10094                Duration::from_millis(50),
10095            )
10096            .await,
10097            Err(SuperviseError::RegistrationStillActive { .. })
10098        ));
10099
10100        let releaser = Arc::clone(&registry);
10101        let release = tokio::spawn(async move {
10102            sleep(Duration::from_millis(20)).await;
10103            releaser
10104                .deregister_connection(ConnectionId::new(INCUMBENT))
10105                .unwrap();
10106            notify_registration_release();
10107        });
10108        wait_for_slot_registration_release(
10109            &registry,
10110            RegistrationSlot::Connection(ConnectionId::new(INCUMBENT)),
10111            Duration::from_secs(5),
10112        )
10113        .await
10114        .expect("the incumbent's own registration is released");
10115        release.await.unwrap();
10116        assert!(registry.get_module("m").unwrap().is_some());
10117    }
10118
10119    /// The candidate slot is waited on separately from the active slot: the
10120    /// incumbent's registration neither holds up nor stands in for it.
10121    #[tokio::test]
10122    async fn candidate_slot_wait_ignores_the_incumbents_registration() {
10123        let registry = swapped_registry();
10124        assert!(matches!(
10125            wait_for_slot_registration_release(
10126                &registry,
10127                RegistrationSlot::Candidate("m"),
10128                Duration::from_millis(50),
10129            )
10130            .await,
10131            Err(SuperviseError::RegistrationStillActive { .. })
10132        ));
10133        registry
10134            .deregister_connection(ConnectionId::new(CANDIDATE))
10135            .unwrap();
10136        wait_for_slot_registration_release(
10137            &registry,
10138            RegistrationSlot::Candidate("m"),
10139            Duration::from_millis(50),
10140        )
10141        .await
10142        .expect("a candidate slot with no candidate is released");
10143        assert!(registry
10144            .registration(RegistrationSlot::Active("m"))
10145            .unwrap()
10146            .is_some());
10147    }
10148}
10149
10150fn classify_exit(status: &ExitStatus) -> ExitReport {
10151    ExitReport {
10152        kind: if status.success() {
10153            ExitKind::Clean
10154        } else {
10155            ExitKind::Crash
10156        },
10157        code: status.code(),
10158        signal: exit_signal(status),
10159        at_ms: unix_ms_now(),
10160    }
10161}
10162
10163/// The terminal record for a module whose `wait()` call itself errored (e.g. the
10164/// child was already reaped out-of-band). There is no `ExitStatus` to read a code
10165/// or signal from -- `None`/`None` is the honest shape, not a guess -- but the
10166/// disposition still must be `Failed` so the terminal ring is not silently missing
10167/// an entry, matching what `fail_snapshot` records for this same arm.
10168fn wait_error_exit_report() -> ExitReport {
10169    ExitReport {
10170        kind: ExitKind::Crash,
10171        code: None,
10172        signal: None,
10173        at_ms: unix_ms_now(),
10174    }
10175}
10176
10177#[cfg(unix)]
10178fn exit_signal(status: &ExitStatus) -> Option<i32> {
10179    use std::os::unix::process::ExitStatusExt;
10180
10181    status.signal()
10182}
10183
10184#[cfg(not(unix))]
10185fn exit_signal(_status: &ExitStatus) -> Option<i32> {
10186    None
10187}
10188
10189/// Give an operator-touched module its full crash budget back.
10190///
10191/// Named for the counter it used to zero; it now empties the in-window ring,
10192/// which is the same act. `lifetime_restarts` is untouched on purpose -- the
10193/// ledger of what happened survives every operator action.
10194fn reset_restart_count(snapshot: &SharedSnapshot, module_id: &str) -> Result<(), SuperviseError> {
10195    update_snapshot(snapshot, Some(module_id), |state| {
10196        state.clear_crash_restarts();
10197    })
10198}
10199
10200fn set_running(
10201    snapshot: &SharedSnapshot,
10202    child: &SupervisedChild,
10203    module_id: &str,
10204    spawn_events: &SpawnEventFeed,
10205) -> Result<(), SuperviseError> {
10206    let mut state = snapshot.lock().map_err(|_| SuperviseError::StatePoisoned {
10207        module_id: Some(module_id.to_string()),
10208    })?;
10209    state.spawn_generation = spawn_events.emit_spawned(module_id, child.pid, child.spawned_at_ms);
10210    if std::mem::take(&mut state.coalesced_restart_pending) {
10211        let generation = state.spawn_generation;
10212        info!(module_id, "restart already satisfied by generation {generation}; coalesced pending request completed");
10213    }
10214    state.drain_disposition_detail = None;
10215    state.spawn_failure = None;
10216    state.waiting_for_previous_pid = None;
10217    // Every caller of this is a plain spawn, which always uses the primary key;
10218    // a promoted swap candidate sets the flag itself after this returns.
10219    state.in_alternate_slot = false;
10220    state.configuration_updated_since_spawn = false;
10221    state.spawned_protocol = Some(child.protocol);
10222    state.state = ModuleState::Running;
10223    state.enabled = true;
10224    state.process_alive = true;
10225    state.pid = child.id();
10226    #[cfg(windows)]
10227    {
10228        state.pipe_consumed = child
10229            .nonce_delivery
10230            .as_ref()
10231            .map(|delivery| delivery.consumed());
10232    }
10233    #[cfg(target_os = "macos")]
10234    {
10235        state.report_ready = Some(Arc::clone(&child.report_ready));
10236    }
10237    state.spawned_at_ms = Some(child.spawned_at_ms);
10238    state.spawned_from = Some(child.spawned_from.clone());
10239    state.spawned_file_identity = child.spawned_file_identity.clone();
10240    state.process_start_time = child.process_start_time;
10241    Ok(())
10242}
10243
10244fn clear_current_process_facts(state: &mut SupervisorSnapshot) {
10245    state.waiting_for_previous_pid = None;
10246    state.process_alive = false;
10247    state.spawned_protocol = None;
10248    state.pid = None;
10249    #[cfg(windows)]
10250    {
10251        state.pipe_consumed = None;
10252    }
10253    #[cfg(target_os = "macos")]
10254    {
10255        state.report_ready = None;
10256    }
10257    state.spawned_at_ms = None;
10258    state.spawned_from = None;
10259    state.spawned_file_identity = None;
10260    state.process_start_time = None;
10261    state.deliberate_severance = None;
10262}
10263
10264#[cfg(test)]
10265fn record_deliberate_severance(
10266    snapshot: &SharedSnapshot,
10267    identity: ProcessIdentity,
10268) -> Result<(), SuperviseError> {
10269    update_snapshot(snapshot, None, |state| {
10270        state.deliberate_severance = Some(identity);
10271    })
10272}
10273
10274fn apply_deliberate_severance_marker(
10275    snapshot: &SharedSnapshot,
10276    exited_identity: Option<ProcessIdentity>,
10277    mut exit_report: ExitReport,
10278) -> ExitReport {
10279    let marker = lock_snapshot(snapshot)
10280        .ok()
10281        .and_then(|mut state| state.deliberate_severance.take());
10282    if marker.is_some() && marker == exited_identity {
10283        exit_report.kind = ExitKind::DeliberateSeverance;
10284    }
10285    exit_report
10286}
10287
10288fn classify_reaped_child_exit(
10289    snapshot: &SharedSnapshot,
10290    child: &SupervisedChild,
10291    status: &ExitStatus,
10292) -> ExitReport {
10293    let _ = update_snapshot(snapshot, None, |state| {
10294        state.reaped_pid = Some(child.pid);
10295        state.spawn_failure = child.spawn_failure.clone();
10296    });
10297    apply_deliberate_severance_marker(snapshot, child.process_identity(), classify_exit(status))
10298}
10299
10300fn fail_snapshot(
10301    snapshot: &SharedSnapshot,
10302    module_id: Option<&str>,
10303    last_exit: Option<ExitReport>,
10304) {
10305    if let Err(err) = update_snapshot(snapshot, module_id, |state| {
10306        state.state = ModuleState::Failed;
10307        clear_current_process_facts(state);
10308        if let Some(last_exit) = last_exit {
10309            state.last_exit = Some(last_exit);
10310        }
10311    }) {
10312        error!(error = %err, "failed to mark supervisor state failed");
10313    }
10314}
10315
10316fn update_snapshot(
10317    snapshot: &SharedSnapshot,
10318    module_id: Option<&str>,
10319    update: impl FnOnce(&mut SupervisorSnapshot),
10320) -> Result<(), SuperviseError> {
10321    let mut state = snapshot.lock().map_err(|_| SuperviseError::StatePoisoned {
10322        module_id: module_id.map(ToOwned::to_owned),
10323    })?;
10324    update(&mut state);
10325    Ok(())
10326}
10327
10328const SLOW_SNAPSHOT_LOCK_THRESHOLD: Duration = Duration::from_millis(250);
10329
10330fn lock_snapshot_for_control<'a>(
10331    snapshot: &'a SharedSnapshot,
10332    module_id: &str,
10333    caller: &'static str,
10334) -> Result<std::sync::MutexGuard<'a, SupervisorSnapshot>, SuperviseError> {
10335    let started_at = Instant::now();
10336    let guard = lock_snapshot(snapshot)?;
10337    let waited = started_at.elapsed();
10338    if waited >= SLOW_SNAPSHOT_LOCK_THRESHOLD {
10339        warn!(
10340            module_id = %module_id,
10341            waited_ms = waited.as_millis() as u64,
10342            caller = %caller,
10343            "slow snapshot lock"
10344        );
10345    }
10346    Ok(guard)
10347}
10348
10349fn lock_snapshot(
10350    snapshot: &SharedSnapshot,
10351) -> Result<std::sync::MutexGuard<'_, SupervisorSnapshot>, SuperviseError> {
10352    snapshot
10353        .lock()
10354        .map_err(|_| SuperviseError::StatePoisoned { module_id: None })
10355}
10356
10357#[cfg(test)]
10358mod terminal_history_tests {
10359    use std::{
10360        path::PathBuf,
10361        sync::Arc,
10362        time::{Duration, Instant},
10363    };
10364
10365    use tokio::{sync::mpsc, time::sleep};
10366
10367    use super::{
10368        apply_deliberate_severance_marker, daemon_will_restart, drain_child_to_state,
10369        drained_after_quiescence_wait, handle_reload_spawn_failure, health_restart_child,
10370        lock_snapshot, on_child_exit, record_deliberate_severance, record_wait_error_terminal,
10371        reset_restart_count, spawn_and_mark_running, update_snapshot, wait_error_exit_report,
10372        ExitKind, ExitReport, ModuleProtocol, ModuleSpec, ModuleState, NextAction, ProcessIdentity,
10373        RestartPolicy, SpawnEventKind, StopNotice, SuperviseError, SupervisedConfiguration,
10374        SupervisedModule, SupervisedModuleInner, Supervisor, SupervisorHandle,
10375        SupervisorHealthStatus, SupervisorSnapshot,
10376    };
10377    // The supervisor's clock, distinct from the `std::time::Instant` these tests
10378    // use for their own wall-clock deadlines: crash-restart instants must be on
10379    // the same clock the production code stamps them with, which is tokio's (and
10380    // is what `start_paused` tests can move).
10381    use super::Instant as ClockInstant;
10382    use crate::{
10383        registry::Registry,
10384        terminal_ring::{TerminalRing, TerminalRingConfig},
10385    };
10386    use std::sync::Mutex;
10387    use subc_control::TerminalDisposition;
10388
10389    /// See the twin in `control.rs` for why this derives the path from
10390    /// `current_exe()` and why the existence check is here: `--lib` alone does
10391    /// not build `[[bin]]` targets, and a bare spawn then fails with a raw
10392    /// `NotFound` that reads as a broken test rather than an unbuilt dependency.
10393    pub(super) fn fake_aft_stub_path() -> PathBuf {
10394        let mut path = std::env::current_exe().expect("current_exe available in tests");
10395        path.pop();
10396        path.pop();
10397        path.push(if cfg!(windows) {
10398            "fake-aft-stub.exe"
10399        } else {
10400            "fake-aft-stub"
10401        });
10402        assert!(
10403            path.exists(),
10404            "fake-aft-stub not built at {}: run `cargo test -p subc-core` (which builds \
10405             [[bin]] targets) rather than `cargo test -p subc-core --lib` (which does not)",
10406            path.display()
10407        );
10408        path
10409    }
10410
10411    #[test]
10412    fn reserved_never_spawned_refuses_every_hello() {
10413        // The canary hole: a reserved id whose module has never spawned had NO
10414        // gate entry and admitted anyone -- the reservation protected the nonce
10415        // holder, not the NAME. Now the entry is present with no legitimate
10416        // holder and refuses all comers.
10417        let supervisor = SupervisorHandle::default();
10418        supervisor.apply_identity_configuration(&ModuleSpec {
10419            module_id: "never-spawned".to_string(),
10420            program: PathBuf::from("/usr/bin/false"),
10421            args: Vec::new(),
10422            env: Vec::new(),
10423            reserved: true,
10424            reserved_prefixes: Vec::new(),
10425            protocol: ModuleProtocol::Subc,
10426            overlap: Default::default(),
10427        });
10428        assert!(
10429            supervisor
10430                .reserved_hello_rejection("never-spawned", Some("any-forged-nonce"))
10431                .is_some(),
10432            "forged nonce must refuse on a reserved never-spawned id"
10433        );
10434        assert!(
10435            supervisor
10436                .reserved_hello_rejection("never-spawned", None)
10437                .is_some(),
10438            "absent nonce must refuse on a reserved never-spawned id"
10439        );
10440        // And a real spawn nonce minted later admits exactly that nonce.
10441        supervisor.set_spawn_nonce("never-spawned", "minted".to_string());
10442        supervisor.apply_identity_configuration(&ModuleSpec {
10443            module_id: "never-spawned".to_string(),
10444            program: PathBuf::from("/usr/bin/false"),
10445            args: Vec::new(),
10446            env: Vec::new(),
10447            reserved: true,
10448            reserved_prefixes: Vec::new(),
10449            protocol: ModuleProtocol::Subc,
10450            overlap: Default::default(),
10451        });
10452        assert!(supervisor
10453            .reserved_hello_rejection("never-spawned", Some("minted"))
10454            .is_none());
10455        assert!(supervisor
10456            .reserved_hello_rejection("never-spawned", Some("forged"))
10457            .is_some());
10458    }
10459
10460    /// Put `count` crash restarts on a snapshot's ring as if they had all just
10461    /// happened, which is what "spent budget" looks like to every reader.
10462    fn seed_crash_restarts(state: &mut SupervisorSnapshot, count: u32) {
10463        let now = ClockInstant::now();
10464        for _ in 0..count {
10465            state.crash_restarts.push_back(now);
10466        }
10467    }
10468
10469    /// Age the oldest recorded restart out of `window`, standing in for the hours
10470    /// that would otherwise have to pass. Injecting the instant is the point: a
10471    /// test that slept a real window would take ten minutes and still prove less.
10472    fn age_oldest_crash_restart_out_of_window(state: &mut SupervisorSnapshot, window: Duration) {
10473        let aged = state
10474            .crash_restarts
10475            .front()
10476            .expect("a crash restart must be recorded before it can be aged")
10477            .checked_sub(window + Duration::from_secs(1))
10478            .expect("the test clock is far enough from its origin to age an instant");
10479        state.crash_restarts[0] = aged;
10480    }
10481
10482    fn snapshot_with_restarts(enabled: bool, count: u32) -> SupervisorSnapshot {
10483        let mut state = SupervisorSnapshot::new(ModuleState::Running, enabled);
10484        seed_crash_restarts(&mut state, count);
10485        state
10486    }
10487
10488    #[test]
10489    fn daemon_owned_recovery_predicate_uses_the_pre_increment_budget() {
10490        let policy = RestartPolicy::new(3, Duration::ZERO);
10491        let now = ClockInstant::now();
10492        assert!(daemon_will_restart(
10493            &mut snapshot_with_restarts(true, 2),
10494            &policy,
10495            now
10496        ));
10497        assert!(!daemon_will_restart(
10498            &mut snapshot_with_restarts(true, 3),
10499            &policy,
10500            now
10501        ));
10502        assert!(!daemon_will_restart(
10503            &mut snapshot_with_restarts(false, 0),
10504            &policy,
10505            now
10506        ));
10507    }
10508
10509    #[test]
10510    fn crash_restart_backoff_escalates_with_in_window_count() {
10511        let policy = RestartPolicy::new(4, Duration::from_millis(100))
10512            .with_max_backoff(Duration::from_secs(30));
10513        let now = ClockInstant::now();
10514        let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
10515        let schedules = (0..4)
10516            .map(|_| {
10517                state
10518                    .next_crash_restart(&policy, now)
10519                    .expect("the test policy allows four crash restarts")
10520            })
10521            .collect::<Vec<_>>();
10522
10523        assert_eq!(
10524            schedules
10525                .iter()
10526                .map(|schedule| schedule.restart_in_window)
10527                .collect::<Vec<_>>(),
10528            vec![0, 1, 2, 3]
10529        );
10530        assert_eq!(
10531            schedules
10532                .iter()
10533                .map(|schedule| schedule.delay)
10534                .collect::<Vec<_>>(),
10535            vec![
10536                Duration::from_millis(100),
10537                Duration::from_secs(1),
10538                Duration::from_secs(10),
10539                Duration::from_secs(30),
10540            ]
10541        );
10542    }
10543
10544    #[test]
10545    fn crash_restart_backoff_resets_after_ring_clear() {
10546        let policy = RestartPolicy::new(3, Duration::from_millis(100));
10547        let now = ClockInstant::now();
10548        let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
10549        assert_eq!(
10550            state.next_crash_restart(&policy, now).unwrap().delay,
10551            Duration::from_millis(100)
10552        );
10553        assert_eq!(
10554            state.next_crash_restart(&policy, now).unwrap().delay,
10555            Duration::from_secs(1)
10556        );
10557
10558        state.clear_crash_restarts();
10559        let schedule = state
10560            .next_crash_restart(&policy, now)
10561            .expect("a cleared ring must allow another restart");
10562        assert_eq!(schedule.restart_in_window, 0);
10563        assert_eq!(schedule.delay, Duration::from_millis(100));
10564    }
10565
10566    #[test]
10567    fn crash_restart_backoff_ignores_aged_restarts() {
10568        let policy = RestartPolicy::new(3, Duration::from_millis(100));
10569        let now = ClockInstant::now();
10570        let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
10571        state
10572            .next_crash_restart(&policy, now)
10573            .expect("the first restart is allowed");
10574        state
10575            .next_crash_restart(&policy, now)
10576            .expect("the second restart is allowed");
10577        state.crash_restarts[0] = now
10578            .checked_sub(policy.window + Duration::from_secs(1))
10579            .expect("the fake clock can age a restart past the window");
10580
10581        let schedule = state
10582            .next_crash_restart(&policy, now)
10583            .expect("an aged restart must release its slot");
10584        assert_eq!(schedule.restart_in_window, 1);
10585        assert_eq!(schedule.delay, Duration::from_secs(1));
10586        assert_eq!(state.crash_restarts.len(), 2);
10587    }
10588
10589    /// The budget is a rate: the same three spent restarts refuse a respawn
10590    /// while they are recent and allow one once they have aged past the window.
10591    /// Nothing about the module changed in between, which is the whole point.
10592    #[test]
10593    fn a_budget_spent_before_the_window_no_longer_refuses() {
10594        let policy = RestartPolicy::new(3, Duration::ZERO);
10595        let mut state = snapshot_with_restarts(true, 3);
10596        let now = ClockInstant::now();
10597        assert!(!daemon_will_restart(&mut state, &policy, now));
10598
10599        assert!(daemon_will_restart(
10600            &mut state,
10601            &policy,
10602            now + policy.window + Duration::from_secs(1)
10603        ));
10604        assert!(
10605            state.crash_restarts.is_empty(),
10606            "reading the budget must drop the instants that left the window"
10607        );
10608    }
10609
10610    fn module_with_recovery_snapshot(
10611        state: ModuleState,
10612        enabled: bool,
10613        restart_count: u32,
10614    ) -> SupervisedModule {
10615        let registry = Arc::new(Registry::default());
10616        let supervisor =
10617            Supervisor::new_for_test(Arc::clone(&registry), RestartPolicy::new(3, Duration::ZERO));
10618        let runtime = supervisor.runtime_config();
10619        let spec = ModuleSpec {
10620            module_id: "recovery-snapshot".to_string(),
10621            program: fake_aft_stub_path(),
10622            args: Vec::new(),
10623            env: Vec::new(),
10624            reserved: false,
10625            reserved_prefixes: Vec::new(),
10626            protocol: ModuleProtocol::Subc,
10627            overlap: Default::default(),
10628        };
10629        let mut snapshot = SupervisorSnapshot::new(state, enabled);
10630        seed_crash_restarts(&mut snapshot, restart_count);
10631        // These tests read synthetic snapshots. A real child and monitor would
10632        // race those reads by replacing the requested state during startup.
10633        let (commands, _rx) = mpsc::channel(4);
10634        SupervisedModule {
10635            inner: Arc::new(SupervisedModuleInner {
10636                module_id: spec.module_id.clone(),
10637                registry,
10638                snapshot: Arc::new(Mutex::new(snapshot)),
10639                configuration: Arc::new(Mutex::new(SupervisedConfiguration {
10640                    spec,
10641                    health: runtime.health,
10642                })),
10643                stderr_ring: runtime.stderr_ring,
10644                terminal_ring: runtime.terminal_ring,
10645                commands,
10646                monitor: Mutex::new(None),
10647                restart_policy: runtime.restart_policy,
10648                effective_drain_timeout: runtime.effective_drain_timeout,
10649                provenance_probe: supervisor.provenance_probe.clone(),
10650            }),
10651        }
10652    }
10653
10654    #[cfg(target_os = "linux")]
10655    #[tokio::test]
10656    async fn no_cgroup_placement_does_not_block_fake_aft_stub_spawn() {
10657        let supervisor =
10658            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
10659                .with_cgroup_placement(None);
10660        let result = supervisor.spawn(ModuleSpec {
10661            module_id: "no-cgroup-placement".to_string(),
10662            program: fake_aft_stub_path(),
10663            args: Vec::new(),
10664            env: Vec::new(),
10665            reserved: false,
10666            reserved_prefixes: Vec::new(),
10667            protocol: ModuleProtocol::Subc,
10668            overlap: Default::default(),
10669        });
10670
10671        assert!(
10672            result.is_ok(),
10673            "no delegation must not turn an otherwise valid spawn into a failure: {result:?}"
10674        );
10675    }
10676
10677    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10678    async fn undecided_snapshot_uses_shared_restart_predicate() {
10679        assert!(module_with_recovery_snapshot(ModuleState::Running, true, 2)
10680            .will_recover_after_connection_loss()
10681            .unwrap());
10682        assert!(
10683            !module_with_recovery_snapshot(ModuleState::Running, true, 3)
10684                .will_recover_after_connection_loss()
10685                .unwrap()
10686        );
10687    }
10688
10689    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10690    async fn restarting_snapshot_at_exhausted_budget_is_non_terminal() {
10691        assert!(
10692            module_with_recovery_snapshot(ModuleState::Restarting, true, 3)
10693                .will_recover_after_connection_loss()
10694                .unwrap()
10695        );
10696    }
10697
10698    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10699    async fn terminal_phase_snapshots_are_terminal_before_budget_exhaustion() {
10700        assert!(!module_with_recovery_snapshot(ModuleState::Failed, true, 0)
10701            .will_recover_after_connection_loss()
10702            .unwrap());
10703        assert!(
10704            !module_with_recovery_snapshot(ModuleState::Disabled, true, 0)
10705                .will_recover_after_connection_loss()
10706                .unwrap()
10707        );
10708    }
10709
10710    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10711    async fn warming_snapshot_is_limited_to_startup_phases() {
10712        for state in [
10713            ModuleState::Starting,
10714            ModuleState::Running,
10715            ModuleState::Restarting,
10716        ] {
10717            assert!(
10718                module_with_recovery_snapshot(state, true, 0)
10719                    .is_warming()
10720                    .unwrap(),
10721                "{state:?} should be warming"
10722            );
10723        }
10724        for state in [
10725            ModuleState::Unresponsive,
10726            ModuleState::Draining,
10727            ModuleState::Stopped,
10728            ModuleState::Failed,
10729            ModuleState::Disabled,
10730        ] {
10731            assert!(
10732                !module_with_recovery_snapshot(state, true, 0)
10733                    .is_warming()
10734                    .unwrap(),
10735                "{state:?} should not be warming"
10736            );
10737        }
10738    }
10739
10740    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10741    async fn terminal_history_survives_respawn_and_keeps_both_crashes_in_order() {
10742        let registry = Arc::new(Registry::default());
10743        let supervisor =
10744            Supervisor::new_for_test(Arc::clone(&registry), RestartPolicy::new(1, Duration::ZERO));
10745        let module = supervisor
10746            .spawn(ModuleSpec {
10747                module_id: "terminal-history".to_string(),
10748                program: fake_aft_stub_path(),
10749                args: Vec::new(),
10750                env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
10751                reserved: false,
10752                reserved_prefixes: Vec::new(),
10753                protocol: ModuleProtocol::Subc,
10754                overlap: Default::default(),
10755            })
10756            .unwrap();
10757
10758        let deadline = Instant::now() + Duration::from_secs(5);
10759        loop {
10760            let history = module.terminal_history();
10761            if history.entries.len() == 2 {
10762                assert_eq!(module.status().unwrap().state, ModuleState::Failed);
10763                assert_eq!(history.dropped, 0);
10764                assert_eq!(
10765                    history
10766                        .entries
10767                        .iter()
10768                        .map(|entry| entry.exit_code)
10769                        .collect::<Vec<_>>(),
10770                    vec![Some(23), Some(23)]
10771                );
10772                assert!(history.entries[0].at_ms <= history.entries[1].at_ms);
10773                return;
10774            }
10775            assert!(
10776                Instant::now() < deadline,
10777                "module did not retain two terminal exits: {history:?}"
10778            );
10779            sleep(Duration::from_millis(10)).await;
10780        }
10781    }
10782
10783    /// A disable issued while a crash respawn is still backing off must preempt
10784    /// that respawn: the operator's stop wins, the disable must not queue behind
10785    /// the backoff, and the module must never come back up afterwards.
10786    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10787    async fn disable_during_crash_backoff_cancels_pending_respawn() {
10788        let backoff = Duration::from_secs(2);
10789        let supervisor = Supervisor::new_for_test(
10790            Arc::new(Registry::default()),
10791            RestartPolicy::new(10, backoff),
10792        );
10793        let module = supervisor
10794            .spawn(ModuleSpec {
10795                module_id: "disable-during-backoff".to_string(),
10796                program: fake_aft_stub_path(),
10797                args: Vec::new(),
10798                env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
10799                reserved: false,
10800                reserved_prefixes: Vec::new(),
10801                protocol: ModuleProtocol::Subc,
10802                overlap: Default::default(),
10803            })
10804            .unwrap();
10805
10806        // Wait for the first crash to put the module into its backoff window.
10807        let deadline = Instant::now() + Duration::from_secs(5);
10808        loop {
10809            if module.status().unwrap().state == ModuleState::Restarting {
10810                break;
10811            }
10812            assert!(
10813                Instant::now() < deadline,
10814                "module never entered the crash backoff"
10815            );
10816            sleep(Duration::from_millis(10)).await;
10817        }
10818
10819        let started = Instant::now();
10820        module.set_enabled(false).await.unwrap();
10821        let waited = started.elapsed();
10822
10823        assert!(
10824            waited < backoff / 2,
10825            "disable waited {waited:?} behind the {backoff:?} crash backoff; the operator command must preempt the pending respawn"
10826        );
10827        assert_eq!(module.status().unwrap().state, ModuleState::Disabled);
10828
10829        // Outlast the backoff: the respawn it was counting down to must never run.
10830        sleep(backoff + Duration::from_millis(500)).await;
10831        let status = module.status().unwrap();
10832        assert_eq!(status.state, ModuleState::Disabled);
10833        assert_eq!(
10834            status.spawn_generation, 1,
10835            "module respawned after the operator disabled it"
10836        );
10837    }
10838
10839    /// A `protocol: "none"` child that stays parked and exits 0 on SIGTERM:
10840    /// the shape of nats-server, the program this rule exists for.
10841    #[cfg(unix)]
10842    fn protocol_none_sigterm_exits_clean_spec(
10843        module_id: &str,
10844        dir: &std::path::Path,
10845    ) -> (ModuleSpec, PathBuf, PathBuf) {
10846        let ready = dir.join("ready");
10847        let marker = dir.join("sigterm");
10848        let spec = ModuleSpec {
10849            module_id: module_id.to_string(),
10850            program: fake_aft_stub_path(),
10851            args: Vec::new(),
10852            env: vec![
10853                ("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string()),
10854                (
10855                    "FAKE_AFT_SIGTERM_MARKER_PATH".to_string(),
10856                    marker.display().to_string(),
10857                ),
10858                (
10859                    "FAKE_AFT_NEVER_CONNECT_READY_PATH".to_string(),
10860                    ready.display().to_string(),
10861                ),
10862            ],
10863            reserved: false,
10864            reserved_prefixes: Vec::new(),
10865            protocol: ModuleProtocol::None,
10866            overlap: Default::default(),
10867        };
10868        (spec, ready, marker)
10869    }
10870
10871    /// Wait for a file the child writes, so a signal is never sent before the
10872    /// child's SIGTERM handler is installed (the default disposition would
10873    /// kill it by signal and the exit would not be clean).
10874    #[cfg(unix)]
10875    async fn wait_for_file(path: &std::path::Path) {
10876        let deadline = Instant::now() + Duration::from_secs(10);
10877        while !path.exists() {
10878            assert!(
10879                Instant::now() < deadline,
10880                "{} never appeared",
10881                path.display()
10882            );
10883            sleep(Duration::from_millis(10)).await;
10884        }
10885    }
10886
10887    /// A protocol-none module that exits 0 because something OUTSIDE the
10888    /// supervisor sent it SIGTERM is respawned, and the terminal record carries
10889    /// the crash-path disposition rather than `stopped`.
10890    #[cfg(unix)]
10891    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10892    async fn protocol_none_unrequested_clean_exit_restarts_as_a_crash() {
10893        let dir = cortexkit_test_support::ScratchDir::new("none-unrequested-clean-exit");
10894        let (spec, ready, marker) =
10895            protocol_none_sigterm_exits_clean_spec("none-unrequested-clean-exit", dir.path());
10896        let supervisor = Supervisor::new_for_test(
10897            Arc::new(Registry::default()),
10898            RestartPolicy::new(3, Duration::ZERO),
10899        );
10900        let module = supervisor.spawn(spec).unwrap();
10901        wait_for_file(&ready).await;
10902        // The ready file proves the child installed its SIGTERM handler, not
10903        // that the supervisor has processed the privacy trampoline's exec
10904        // acknowledgement. On macOS status withholds the pid until then.
10905        let deadline = Instant::now() + Duration::from_secs(10);
10906        let first_pid = loop {
10907            let status = module.status().unwrap();
10908            if status.state == ModuleState::Running {
10909                if let Some(pid) = status.pid {
10910                    break pid;
10911                }
10912            }
10913            assert!(
10914                Instant::now() < deadline,
10915                "a running module must report its pid after exec confirmation: {status:?}"
10916            );
10917            sleep(Duration::from_millis(10)).await;
10918        };
10919
10920        rustix::process::kill_process(
10921            rustix::process::Pid::from_raw(i32::try_from(first_pid).unwrap()).unwrap(),
10922            rustix::process::Signal::TERM,
10923        )
10924        .unwrap();
10925
10926        let deadline = Instant::now() + Duration::from_secs(10);
10927        let respawned = loop {
10928            let status = module.status().unwrap();
10929            if status.state == ModuleState::Running
10930                && status.pid.is_some_and(|pid| pid != first_pid)
10931            {
10932                break status;
10933            }
10934            assert!(
10935                Instant::now() < deadline,
10936                "protocol-none module was not respawned after an unrequested clean exit: {status:?}"
10937            );
10938            sleep(Duration::from_millis(10)).await;
10939        };
10940        assert_eq!(respawned.spawn_generation, 2);
10941        assert!(
10942            marker.exists(),
10943            "the child must have exited through its SIGTERM handler (exit 0), or this proves nothing about clean exits"
10944        );
10945
10946        let history = module.terminal_history();
10947        assert_eq!(history.entries.len(), 1, "{history:?}");
10948        let entry = &history.entries[0];
10949        assert_eq!(entry.exit_code, Some(0));
10950        assert_eq!(entry.exit_kind, subc_control::TerminalExitKind::Clean);
10951        assert_eq!(entry.disposition, TerminalDisposition::Restarting);
10952
10953        module.stop().await.unwrap();
10954    }
10955
10956    /// Repeated unrequested clean exits of a protocol-none module spend the
10957    /// restart budget exactly as crashes do, and the module ends `failed` with
10958    /// the budget named.
10959    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10960    async fn protocol_none_repeated_clean_exits_exhaust_the_restart_budget() {
10961        let supervisor = Supervisor::new_for_test(
10962            Arc::new(Registry::default()),
10963            RestartPolicy::new(1, Duration::ZERO),
10964        );
10965        let module = supervisor
10966            .spawn(ModuleSpec {
10967                module_id: "none-clean-exit-budget".to_string(),
10968                program: fake_aft_stub_path(),
10969                args: Vec::new(),
10970                env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "0".to_string())],
10971                reserved: false,
10972                reserved_prefixes: Vec::new(),
10973                protocol: ModuleProtocol::None,
10974                overlap: Default::default(),
10975            })
10976            .unwrap();
10977
10978        // Failed follows the terminal write; this deadline only bounds a hang,
10979        // not an assumed duration for the two launches or their exit recording.
10980        let deadline = Instant::now() + Duration::from_secs(10);
10981        loop {
10982            let status = module.status().unwrap();
10983            if status.state == ModuleState::Failed {
10984                break;
10985            }
10986            assert!(
10987                Instant::now() < deadline,
10988                "module never exhausted its budget: {status:?} {:?}",
10989                module.terminal_history()
10990            );
10991            sleep(Duration::from_millis(10)).await;
10992        }
10993        let history = module.terminal_history();
10994        assert_eq!(
10995            history
10996                .entries
10997                .iter()
10998                .map(|entry| (entry.exit_code, entry.disposition.clone()))
10999                .collect::<Vec<_>>(),
11000            vec![
11001                (Some(0), TerminalDisposition::Restarting),
11002                (Some(0), TerminalDisposition::Failed),
11003            ]
11004        );
11005        let detail = history.entries[1]
11006            .disposition_detail
11007            .as_deref()
11008            .expect("a budget failure names the budget");
11009        assert!(detail.contains("max_restarts=1"), "{detail}");
11010        assert_eq!(module.status().unwrap().spawn_generation, 2);
11011    }
11012
11013    #[test]
11014    fn restart_budget_failure_is_published_after_its_terminal_record() {
11015        use std::io::{Read, Write};
11016        let supervisor = Supervisor::new_for_test(
11017            Arc::new(Registry::default()),
11018            RestartPolicy::new(0, Duration::ZERO),
11019        );
11020        let runtime = supervisor.runtime_config();
11021        let spec = ModuleSpec {
11022            protocol: ModuleProtocol::None,
11023            ..windowed_crash_spec("budget-publication-order")
11024        };
11025        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(
11026            ModuleState::Running,
11027            true,
11028        )));
11029        lock_snapshot(&snapshot).unwrap().process_alive = true;
11030        let reaped_snapshot = snapshot.clone();
11031        let mut barrier = terminal_record_barrier(&runtime.spawn_events);
11032        let history = runtime.terminal_ring.clone();
11033        let reap = std::thread::spawn(move || {
11034            tokio::runtime::Builder::new_current_thread()
11035                .enable_all()
11036                .build()
11037                .unwrap()
11038                .block_on(on_child_exit(
11039                    &spec,
11040                    runtime.restart_policy,
11041                    &supervisor.registry,
11042                    &reaped_snapshot,
11043                    &runtime.terminal_ring,
11044                    &runtime.spawn_events,
11045                    &runtime.child_roster,
11046                    ExitReport {
11047                        kind: ExitKind::Clean,
11048                        code: Some(0),
11049                        signal: None,
11050                        at_ms: 1,
11051                    },
11052                ))
11053        });
11054        let mut reached = [0];
11055        barrier.read_exact(&mut reached).unwrap();
11056        assert_eq!(&reached, b"R");
11057        let (before_state, before_alive) = {
11058            let state = lock_snapshot(&snapshot).unwrap();
11059            (state.state, state.process_alive)
11060        };
11061        let before_history = history.lock().unwrap().snapshot();
11062        barrier.write_all(b"X").unwrap();
11063        assert!(matches!(reap.join().unwrap(), NextAction::Stop { .. }));
11064        assert!(before_history.entries.is_empty());
11065        assert_ne!(
11066            before_state,
11067            ModuleState::Failed,
11068            "Failed was visible before its terminal record could be written"
11069        );
11070        assert!(before_alive);
11071        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
11072        let history = history.lock().unwrap().snapshot();
11073        assert_eq!(history.entries.len(), 1);
11074        assert_eq!(history.entries[0].exit_code, Some(0));
11075        assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
11076    }
11077
11078    #[test]
11079    fn crash_restart_at_budget_limit_remains_recoverable_while_recording() {
11080        use std::io::{Read, Write};
11081
11082        let supervisor = Supervisor::new_for_test(
11083            Arc::new(Registry::default()),
11084            RestartPolicy::new(3, Duration::ZERO),
11085        );
11086        let mut runtime = supervisor.runtime_config();
11087        let module = module_with_recovery_snapshot(ModuleState::Running, true, 2);
11088        let snapshot = module.inner.snapshot.clone();
11089        lock_snapshot(&snapshot).unwrap().process_alive = true;
11090        runtime.terminal_ring = module.inner.terminal_ring.clone();
11091        let spec = windowed_crash_spec(module.module_id());
11092        let mut barrier = terminal_record_barrier(&runtime.spawn_events);
11093        let reaped_snapshot = snapshot.clone();
11094        let reap = std::thread::spawn(move || {
11095            tokio::runtime::Builder::new_current_thread()
11096                .enable_all()
11097                .build()
11098                .unwrap()
11099                .block_on(on_child_exit(
11100                    &spec,
11101                    runtime.restart_policy,
11102                    &supervisor.registry,
11103                    &reaped_snapshot,
11104                    &runtime.terminal_ring,
11105                    &runtime.spawn_events,
11106                    &runtime.child_roster,
11107                    ExitReport {
11108                        kind: ExitKind::Crash,
11109                        code: Some(2),
11110                        signal: None,
11111                        at_ms: 1,
11112                    },
11113                ))
11114        });
11115
11116        // Hold the crash handler between its budget decision and publication.
11117        // Connection cleanup must still see the recovery that this crash chose,
11118        // even when that recovery consumes the last available restart slot.
11119        let mut reached = [0];
11120        barrier.read_exact(&mut reached).unwrap();
11121        assert_eq!(&reached, b"R");
11122        let before_status = module.status().unwrap();
11123        let before_history = module.terminal_history();
11124        let before_recovers = module.will_recover_after_connection_loss().unwrap();
11125        barrier.write_all(b"X").unwrap();
11126        assert!(matches!(reap.join().unwrap(), NextAction::Restart { .. }));
11127
11128        assert_eq!(before_status.state, ModuleState::Running);
11129        assert!(before_status.process_alive);
11130        assert_eq!(before_status.restart_count, 3);
11131        assert!(before_history.entries.is_empty());
11132        assert!(
11133            before_recovers,
11134            "connection cleanup would report a terminal close for a scheduled crash restart"
11135        );
11136        let restarting = module.status().unwrap();
11137        assert_eq!(restarting.state, ModuleState::Restarting);
11138        assert_eq!(restarting.restart_count, 3);
11139        assert!(module.will_recover_after_connection_loss().unwrap());
11140        let history = module.terminal_history();
11141        assert_eq!(history.entries.len(), 1);
11142        assert_eq!(
11143            history.entries[0].disposition,
11144            TerminalDisposition::Restarting
11145        );
11146
11147        // Once the replacement is running, its next crash must be judged
11148        // against the exhausted budget rather than the previous recovery.
11149        update_snapshot(&snapshot, Some(module.module_id()), |state| {
11150            state.state = ModuleState::Running;
11151            state.respawn_pending = false;
11152        })
11153        .unwrap();
11154        assert!(!module.will_recover_after_connection_loss().unwrap());
11155        update_snapshot(&snapshot, Some(module.module_id()), |state| {
11156            state.enabled = false;
11157            state.respawn_pending = true;
11158        })
11159        .unwrap();
11160        assert!(!module.will_recover_after_connection_loss().unwrap());
11161    }
11162
11163    fn terminal_record_barrier(events: &super::SpawnEventFeed) -> std::net::TcpStream {
11164        use std::net::{TcpListener, TcpStream};
11165        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
11166        let writer = TcpStream::connect(listener.local_addr().unwrap()).unwrap();
11167        let (barrier, _) = listener.accept().unwrap();
11168        for stream in [&writer, &barrier] {
11169            stream
11170                .set_read_timeout(Some(Duration::from_secs(10)))
11171                .unwrap();
11172            stream
11173                .set_write_timeout(Some(Duration::from_secs(10)))
11174                .unwrap();
11175        }
11176        *events.1.lock().unwrap() = Some(writer);
11177        barrier
11178    }
11179
11180    pub(super) fn before_terminal_record(events: &super::SpawnEventFeed) {
11181        use std::io::{Read, Write};
11182        let barrier = events.1.lock().unwrap().take();
11183        if let Some(mut barrier) = barrier {
11184            barrier.write_all(b"R").unwrap();
11185            let mut release = [0];
11186            barrier.read_exact(&mut release).unwrap();
11187            assert_eq!(&release, b"X");
11188        }
11189    }
11190
11191    #[test]
11192    fn health_restart_budget_failure_is_published_after_its_terminal_record() {
11193        use std::io::{Read, Write};
11194
11195        let supervisor = Supervisor::new_for_test(
11196            Arc::new(Registry::default()),
11197            RestartPolicy::new(0, Duration::ZERO),
11198        );
11199        let mut runtime = supervisor.runtime_config();
11200        let module = module_with_recovery_snapshot(ModuleState::Running, true, 0);
11201        let snapshot = module.inner.snapshot.clone();
11202        runtime.terminal_ring = module.inner.terminal_ring.clone();
11203        let spec = ModuleSpec {
11204            module_id: module.module_id().into(),
11205            program: fake_aft_stub_path(),
11206            protocol: ModuleProtocol::None,
11207            env: vec![("FAKE_AFT_EXIT_CODE".into(), "17".into())],
11208            ..windowed_crash_spec(module.module_id())
11209        };
11210        let mut barrier = terminal_record_barrier(&runtime.spawn_events);
11211        let reap = std::thread::spawn(move || {
11212            tokio::runtime::Builder::new_current_thread()
11213                .enable_all()
11214                .build()
11215                .unwrap()
11216                .block_on(async {
11217                    let mut child =
11218                        Some(spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap());
11219                    health_restart_child(
11220                        &spec,
11221                        &runtime,
11222                        &supervisor.registry,
11223                        &supervisor.process_liveness,
11224                        &snapshot,
11225                        &mut child,
11226                        SupervisorHealthStatus::Failing,
11227                        Some("health probe did not answer"),
11228                        1,
11229                    )
11230                    .await
11231                    .unwrap();
11232                    assert!(child.is_none());
11233                });
11234        });
11235        // The loopback barrier acknowledges entry into terminal recording by
11236        // health_restart_child's drain, then waits for the test's release byte.
11237        // Status and history are observed while that writer is held, not after
11238        // an elapsed-time guess about when the health action has completed.
11239        let mut reached = [0];
11240        barrier.read_exact(&mut reached).unwrap();
11241        assert_eq!(&reached, b"R");
11242        let before_status = module.status().unwrap();
11243        let before_history = module.terminal_history();
11244        barrier.write_all(b"X").unwrap();
11245        reap.join().unwrap();
11246        assert!(before_history.entries.is_empty());
11247        assert_ne!(
11248            before_status.state,
11249            ModuleState::Failed,
11250            "Failed was visible before its terminal record could be written"
11251        );
11252        assert!(
11253            before_status.process_alive,
11254            "process death was visible before its terminal record could be written"
11255        );
11256        let failed = module.status().unwrap();
11257        assert_eq!(failed.state, ModuleState::Failed);
11258        assert!(!failed.process_alive);
11259        let history = module.terminal_history();
11260        assert_eq!(history.entries.len(), 1);
11261        assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
11262        assert_eq!(
11263            history.entries[0].disposition_detail.as_deref(),
11264            Some("crash budget exhausted: max_restarts=0 within window_secs=600")
11265        );
11266    }
11267
11268    #[test]
11269    fn observed_exits_publish_process_death_after_terminal_history() {
11270        use std::io::{Read, Write};
11271        for (kind, enabled, shutdown, wait_error, expected_state) in [
11272            (ExitKind::Clean, true, false, false, ModuleState::Stopped),
11273            (ExitKind::Crash, true, false, false, ModuleState::Restarting),
11274            (ExitKind::Crash, false, false, false, ModuleState::Disabled),
11275            (
11276                ExitKind::DeliberateSeverance,
11277                true,
11278                false,
11279                false,
11280                ModuleState::Restarting,
11281            ),
11282            #[cfg(unix)]
11283            (ExitKind::Crash, true, true, false, ModuleState::Stopped),
11284            (ExitKind::Crash, true, false, true, ModuleState::Failed),
11285        ] {
11286            let supervisor = Supervisor::new_for_test(
11287                Arc::new(Registry::default()),
11288                RestartPolicy::new(1, Duration::ZERO),
11289            );
11290            let runtime = supervisor.runtime_config();
11291            #[cfg(unix)]
11292            if shutdown {
11293                runtime.child_roster.close();
11294            }
11295            #[cfg(not(unix))]
11296            let _ = shutdown;
11297            let spec = windowed_crash_spec("exit-publication-order");
11298            let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(
11299                ModuleState::Running,
11300                enabled,
11301            )));
11302            lock_snapshot(&snapshot).unwrap().process_alive = true;
11303            let reaped_snapshot = snapshot.clone();
11304            let history = runtime.terminal_ring.clone();
11305            let mut barrier = terminal_record_barrier(&runtime.spawn_events);
11306            let reap = std::thread::spawn(move || {
11307                if wait_error {
11308                    super::fail_after_wait_error(
11309                        &spec.module_id,
11310                        &reaped_snapshot,
11311                        &runtime.terminal_ring,
11312                        &runtime.spawn_events,
11313                        "wait failed".into(),
11314                    );
11315                } else {
11316                    tokio::runtime::Builder::new_current_thread()
11317                        .enable_all()
11318                        .build()
11319                        .unwrap()
11320                        .block_on(on_child_exit(
11321                            &spec,
11322                            runtime.restart_policy,
11323                            &supervisor.registry,
11324                            &reaped_snapshot,
11325                            &runtime.terminal_ring,
11326                            &runtime.spawn_events,
11327                            &runtime.child_roster,
11328                            ExitReport {
11329                                kind,
11330                                code: Some(0),
11331                                signal: None,
11332                                at_ms: 1,
11333                            },
11334                        ));
11335                }
11336            });
11337            let mut reached = [0];
11338            barrier.read_exact(&mut reached).unwrap();
11339            assert_eq!(&reached, b"R");
11340            let before_alive = lock_snapshot(&snapshot).unwrap().process_alive;
11341            let before_history = history.lock().unwrap().snapshot();
11342            barrier.write_all(b"X").unwrap();
11343            reap.join().unwrap();
11344            assert!(
11345                before_alive,
11346                "{kind:?}: death was published before recording"
11347            );
11348            assert!(before_history.entries.is_empty());
11349            assert_eq!(lock_snapshot(&snapshot).unwrap().state, expected_state);
11350            assert!(!lock_snapshot(&snapshot).unwrap().process_alive);
11351            assert_eq!(history.lock().unwrap().snapshot().entries.len(), 1);
11352        }
11353    }
11354
11355    #[tokio::test]
11356    async fn health_restart_budget_failure_remains_failed_when_journal_append_fails() {
11357        let dir = cortexkit_test_support::ScratchDir::new("health-budget-journal-failure");
11358        let path = dir.join("terminals.jsonl");
11359        std::fs::create_dir(&path).unwrap();
11360        let supervisor = Supervisor::new_for_test(
11361            Arc::new(Registry::default()),
11362            RestartPolicy::new(0, Duration::ZERO),
11363        )
11364        .with_terminal_journal(path, "health-budget-journal-failure".into());
11365        let runtime = supervisor.runtime_config();
11366        let spec = ModuleSpec {
11367            program: fake_aft_stub_path(),
11368            protocol: ModuleProtocol::None,
11369            env: vec![("FAKE_AFT_EXIT_CODE".into(), "17".into())],
11370            ..windowed_crash_spec("health-budget-journal-failure")
11371        };
11372        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11373        let mut child = Some(spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap());
11374        health_restart_child(
11375            &spec,
11376            &runtime,
11377            &supervisor.registry,
11378            &supervisor.process_liveness,
11379            &snapshot,
11380            &mut child,
11381            SupervisorHealthStatus::Failing,
11382            None,
11383            1,
11384        )
11385        .await
11386        .unwrap();
11387        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
11388        assert!(!lock_snapshot(&snapshot).unwrap().process_alive);
11389        let history = runtime
11390            .terminal_ring
11391            .lock()
11392            .unwrap()
11393            .durable_history(&spec.module_id);
11394        assert!(history.journal_write_failures > 0);
11395        assert_eq!(history.entries.len(), 1);
11396        assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
11397    }
11398
11399    #[test]
11400    fn health_restart_budget_failure_remains_failed_when_exit_recording_panics() {
11401        let supervisor = Supervisor::new_for_test(
11402            Arc::new(Registry::default()),
11403            RestartPolicy::new(0, Duration::ZERO),
11404        );
11405        let runtime = supervisor.runtime_config();
11406        let spec = ModuleSpec {
11407            program: fake_aft_stub_path(),
11408            protocol: ModuleProtocol::None,
11409            env: vec![("FAKE_AFT_EXIT_CODE".into(), "17".into())],
11410            ..windowed_crash_spec("health-budget-recording-panic")
11411        };
11412        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11413        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
11414            tokio::runtime::Builder::new_current_thread()
11415                .enable_all()
11416                .build()
11417                .unwrap()
11418                .block_on(async {
11419                    let mut child =
11420                        Some(spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap());
11421                    runtime.spawn_events.0.lock().unwrap().seq = u64::MAX;
11422                    health_restart_child(
11423                        &spec,
11424                        &runtime,
11425                        &supervisor.registry,
11426                        &supervisor.process_liveness,
11427                        &snapshot,
11428                        &mut child,
11429                        SupervisorHealthStatus::Failing,
11430                        None,
11431                        1,
11432                    )
11433                    .await
11434                    .unwrap();
11435                });
11436        }));
11437        let panic = result.expect_err("recording must still unwind");
11438        assert_eq!(
11439            panic.downcast_ref::<String>().map(String::as_str),
11440            Some("spawn event sequence exhausted")
11441        );
11442        let state = lock_snapshot(&snapshot).unwrap();
11443        assert_eq!(state.state, ModuleState::Failed);
11444        assert!(!state.process_alive);
11445        assert!(state.last_exit.is_some());
11446    }
11447
11448    #[tokio::test]
11449    async fn restart_budget_failure_remains_failed_when_journal_append_fails() {
11450        let dir = cortexkit_test_support::ScratchDir::new("budget-journal-failure");
11451        let path = dir.join("terminals.jsonl");
11452        std::fs::create_dir(&path).unwrap();
11453        let supervisor = Supervisor::new_for_test(
11454            Arc::new(Registry::default()),
11455            RestartPolicy::new(0, Duration::ZERO),
11456        )
11457        .with_terminal_journal(path, "budget-journal-failure".into());
11458        let runtime = supervisor.runtime_config();
11459        let spec = windowed_crash_spec("budget-journal-failure");
11460        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11461        assert!(matches!(
11462            on_child_exit(
11463                &spec,
11464                runtime.restart_policy,
11465                &supervisor.registry,
11466                &snapshot,
11467                &runtime.terminal_ring,
11468                &runtime.spawn_events,
11469                &runtime.child_roster,
11470                crash_exit_report(1),
11471            )
11472            .await,
11473            NextAction::Stop { .. }
11474        ));
11475        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
11476        let history = runtime
11477            .terminal_ring
11478            .lock()
11479            .unwrap()
11480            .durable_history(&spec.module_id);
11481        assert!(history.journal_write_failures > 0);
11482        assert_eq!(history.entries.len(), 1);
11483        assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
11484    }
11485
11486    #[test]
11487    fn restart_budget_failure_remains_failed_when_exit_recording_panics() {
11488        let supervisor = Supervisor::new_for_test(
11489            Arc::new(Registry::default()),
11490            RestartPolicy::new(0, Duration::ZERO),
11491        );
11492        let runtime = supervisor.runtime_config();
11493        let spec = windowed_crash_spec("budget-recording-panic");
11494        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11495        runtime.spawn_events.emit_spawned(&spec.module_id, 1, 1);
11496        // Exhausting the event sequence makes emit_exited panic before the
11497        // terminal write, exercising publication on the recording unwind.
11498        runtime.spawn_events.0.lock().unwrap().seq = u64::MAX;
11499        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
11500            tokio::runtime::Builder::new_current_thread()
11501                .enable_all()
11502                .build()
11503                .unwrap()
11504                .block_on(on_child_exit(
11505                    &spec,
11506                    runtime.restart_policy,
11507                    &supervisor.registry,
11508                    &snapshot,
11509                    &runtime.terminal_ring,
11510                    &runtime.spawn_events,
11511                    &runtime.child_roster,
11512                    crash_exit_report(1),
11513                ))
11514        }));
11515        let panic = result.err().expect("recording must still unwind");
11516        assert_eq!(
11517            panic.downcast_ref::<String>().map(String::as_str),
11518            Some("spawn event sequence exhausted")
11519        );
11520        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
11521    }
11522
11523    /// A stop the supervisor itself requests still stops a protocol-none
11524    /// module, even though the child answers the SIGTERM with exit 0.
11525    #[cfg(unix)]
11526    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11527    async fn protocol_none_requested_stop_and_disable_do_not_respawn() {
11528        for disable in [false, true] {
11529            let label = if disable {
11530                "none-requested-disable"
11531            } else {
11532                "none-requested-stop"
11533            };
11534            let dir = cortexkit_test_support::ScratchDir::new(label);
11535            let (spec, ready, marker) = protocol_none_sigterm_exits_clean_spec(label, dir.path());
11536            let supervisor = Supervisor::new_for_test(
11537                Arc::new(Registry::default()),
11538                RestartPolicy::new(3, Duration::ZERO),
11539            );
11540            let module = supervisor.spawn(spec).unwrap();
11541            wait_for_file(&ready).await;
11542
11543            if disable {
11544                module.set_enabled(false).await.unwrap();
11545            } else {
11546                module.stop().await.unwrap();
11547            }
11548            assert!(
11549                marker.exists(),
11550                "{label}: the child must have left through its SIGTERM handler with exit 0"
11551            );
11552
11553            // Long enough for a zero-backoff respawn to have happened if the
11554            // exit had been treated as a crash.
11555            sleep(Duration::from_millis(500)).await;
11556            let status = module.status().unwrap();
11557            let expected = if disable {
11558                ModuleState::Disabled
11559            } else {
11560                ModuleState::Stopped
11561            };
11562            assert_eq!(status.state, expected, "{label}");
11563            assert_eq!(
11564                status.spawn_generation, 1,
11565                "{label}: respawned after a requested stop"
11566            );
11567            let history = module.terminal_history();
11568            assert_eq!(history.entries.len(), 1, "{label}: {history:?}");
11569            assert_eq!(history.entries[0].exit_code, Some(0), "{label}");
11570            assert_ne!(
11571                history.entries[0].disposition,
11572                TerminalDisposition::Restarting,
11573                "{label}"
11574            );
11575        }
11576    }
11577
11578    /// A subc-wire module that exits 0 on its own is still a stop: the
11579    /// protocol-none rule must not reach it.
11580    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11581    async fn subc_wire_clean_exit_is_still_a_stop() {
11582        let supervisor = Supervisor::new_for_test(
11583            Arc::new(Registry::default()),
11584            RestartPolicy::new(3, Duration::ZERO),
11585        );
11586        let module = supervisor
11587            .spawn(ModuleSpec {
11588                module_id: "wire-clean-exit".to_string(),
11589                program: fake_aft_stub_path(),
11590                args: Vec::new(),
11591                env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "0".to_string())],
11592                reserved: false,
11593                reserved_prefixes: Vec::new(),
11594                protocol: ModuleProtocol::Subc,
11595                overlap: Default::default(),
11596            })
11597            .unwrap();
11598
11599        let deadline = Instant::now() + Duration::from_secs(10);
11600        while module.terminal_history().entries.is_empty() {
11601            assert!(Instant::now() < deadline, "module never exited");
11602            sleep(Duration::from_millis(10)).await;
11603        }
11604        // Long enough for a zero-backoff respawn to have happened.
11605        sleep(Duration::from_millis(500)).await;
11606        let status = module.status().unwrap();
11607        assert_eq!(status.state, ModuleState::Stopped);
11608        assert_eq!(status.spawn_generation, 1);
11609        let history = module.terminal_history();
11610        assert_eq!(history.entries.len(), 1, "{history:?}");
11611        assert_eq!(history.entries[0].exit_code, Some(0));
11612        assert_eq!(history.entries[0].disposition, TerminalDisposition::Stopped);
11613    }
11614
11615    #[cfg(unix)]
11616    #[tokio::test]
11617    async fn reload_exit_keeps_roster_until_terminal_is_recorded_during_shutdown() {
11618        let dir = cortexkit_test_support::ScratchDir::new("reload-roster-terminal-order");
11619        let record = dir.join("live-children.json");
11620        let supervisor = Supervisor::new_for_test(
11621            Arc::new(Registry::default()),
11622            RestartPolicy::new(0, Duration::ZERO),
11623        );
11624        let mut runtime = supervisor.runtime_config();
11625        runtime.child_roster.record_to(record.clone());
11626        let gate = Arc::new(super::ReloadExitRecordGate::default());
11627        runtime.test_reload_exit_record_gate = Some(Arc::clone(&gate));
11628        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11629        let spec = ModuleSpec {
11630            module_id: "reload-exit-roster".into(),
11631            program: fake_aft_stub_path(),
11632            args: Vec::new(),
11633            env: vec![("FAKE_AFT_EXIT_CODE".into(), "23".into())],
11634            reserved: false,
11635            reserved_prefixes: Vec::new(),
11636            protocol: ModuleProtocol::Subc,
11637            overlap: Default::default(),
11638        };
11639        let mut child = None;
11640        let reload = super::finish_reload_child(
11641            &spec,
11642            &runtime,
11643            &supervisor.registry,
11644            &supervisor.process_liveness,
11645            &snapshot,
11646            &mut child,
11647        );
11648        tokio::pin!(reload);
11649        tokio::select! {
11650            result = &mut reload => panic!("reload missed the before-record gate: {result:?}"),
11651            _ = gate.reached.notified() => {}
11652        }
11653        assert!(runtime
11654            .terminal_ring
11655            .lock()
11656            .unwrap()
11657            .snapshot()
11658            .entries
11659            .is_empty());
11660        assert_eq!(
11661            crate::live_children::read_record(&record).unwrap().len(),
11662            1,
11663            "shutdown must still wait for the reaped child until its terminal record exists"
11664        );
11665        runtime.child_roster.close();
11666        gate.resume.notify_one();
11667        assert!(reload.await.is_err());
11668        assert!(crate::live_children::read_record(&record)
11669            .unwrap()
11670            .is_empty());
11671        let history = runtime.terminal_ring.lock().unwrap().snapshot();
11672        assert_eq!(history.entries.len(), 1);
11673        assert_eq!(
11674            history.entries[0].disposition,
11675            TerminalDisposition::DaemonShutdown
11676        );
11677    }
11678
11679    /// Each restart-producing arm has its own state transition. Keeping their
11680    /// lifetime count assertions adjacent prevents a later new arm from silently
11681    /// spending budget without recording the historical restart.
11682    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11683    async fn every_restart_increment_path_advances_lifetime_count() {
11684        let supervisor = Supervisor::new_for_test(
11685            Arc::new(Registry::default()),
11686            RestartPolicy::new(1, Duration::ZERO),
11687        );
11688        let runtime = supervisor.runtime_config();
11689        let spec = ModuleSpec {
11690            module_id: "lifetime-increment-path".to_string(),
11691            program: PathBuf::from("/unused/lifetime-increment-path"),
11692            args: Vec::new(),
11693            env: Vec::new(),
11694            reserved: false,
11695            reserved_prefixes: Vec::new(),
11696            protocol: ModuleProtocol::Subc,
11697            overlap: Default::default(),
11698        };
11699
11700        let crash_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11701        assert!(matches!(
11702            on_child_exit(
11703                &spec,
11704                runtime.restart_policy,
11705                &supervisor.registry,
11706                &crash_snapshot,
11707                &runtime.terminal_ring,
11708                &runtime.spawn_events,
11709                &runtime.child_roster,
11710                ExitReport {
11711                    kind: ExitKind::Crash,
11712                    code: Some(1),
11713                    signal: None,
11714                    at_ms: 1,
11715                },
11716            )
11717            .await,
11718            NextAction::Restart { schedule: _ }
11719        ));
11720        let (crash_restarts, crash_lifetime) = {
11721            let state = lock_snapshot(&crash_snapshot).unwrap();
11722            (state.crash_restarts.len(), state.lifetime_restarts)
11723        };
11724        assert_eq!(crash_restarts, 1);
11725        assert_eq!(crash_lifetime, 1);
11726
11727        let health_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11728        let mut health_child = None;
11729        assert!(matches!(
11730            health_restart_child(
11731                &spec,
11732                &runtime,
11733                &supervisor.registry,
11734                &supervisor.process_liveness,
11735                &health_snapshot,
11736                &mut health_child,
11737                SupervisorHealthStatus::Failing,
11738                None,
11739                2,
11740            )
11741            .await,
11742            Ok(())
11743        ));
11744        assert!(health_child.is_none());
11745        assert!(lock_snapshot(&health_snapshot).unwrap().respawn_pending);
11746        assert!(runtime.scheduled_respawn.lock().unwrap().take().is_some());
11747        let (health_restarts, health_lifetime) = {
11748            let state = lock_snapshot(&health_snapshot).unwrap();
11749            (state.crash_restarts.len(), state.lifetime_restarts)
11750        };
11751        assert_eq!(health_restarts, 1);
11752        assert_eq!(health_lifetime, 1);
11753
11754        let reload_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11755        let mut reload_child = None;
11756        assert!(matches!(
11757            handle_reload_spawn_failure(
11758                &spec,
11759                &runtime,
11760                &supervisor.process_liveness,
11761                &reload_snapshot,
11762                &mut reload_child,
11763                "forced reload spawn failure".to_string(),
11764            )
11765            .await,
11766            Err(SuperviseError::ReloadFailed { .. })
11767        ));
11768        let (reload_restarts, reload_lifetime) = {
11769            let state = lock_snapshot(&reload_snapshot).unwrap();
11770            (state.crash_restarts.len(), state.lifetime_restarts)
11771        };
11772        assert_eq!(reload_restarts, 1);
11773        assert_eq!(reload_lifetime, 1);
11774    }
11775
11776    #[tokio::test]
11777    async fn deliberately_severed_live_child_records_lifetime_without_spending_restart_budget() {
11778        let supervisor = Supervisor::new_for_test(
11779            Arc::new(Registry::default()),
11780            RestartPolicy::new(3, Duration::ZERO),
11781        );
11782        let runtime = supervisor.runtime_config();
11783        let spec = ModuleSpec {
11784            module_id: "deliberately-severed".to_string(),
11785            program: PathBuf::from("/unused/deliberately-severed"),
11786            args: Vec::new(),
11787            env: Vec::new(),
11788            reserved: false,
11789            reserved_prefixes: Vec::new(),
11790            protocol: ModuleProtocol::Subc,
11791            overlap: Default::default(),
11792        };
11793        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11794        let process = ProcessIdentity {
11795            pid: 41,
11796            start_time: 101,
11797        };
11798        record_deliberate_severance(&snapshot, process).unwrap();
11799        let exit_report = apply_deliberate_severance_marker(
11800            &snapshot,
11801            Some(process),
11802            ExitReport {
11803                kind: ExitKind::Crash,
11804                code: Some(1),
11805                signal: None,
11806                at_ms: 1,
11807            },
11808        );
11809        assert_eq!(exit_report.kind, ExitKind::DeliberateSeverance);
11810
11811        assert!(matches!(
11812            on_child_exit(
11813                &spec,
11814                runtime.restart_policy,
11815                &supervisor.registry,
11816                &snapshot,
11817                &runtime.terminal_ring,
11818                &runtime.spawn_events,
11819                &runtime.child_roster,
11820                exit_report,
11821            )
11822            .await,
11823            NextAction::Restart { schedule: _ }
11824        ));
11825        let state = lock_snapshot(&snapshot).unwrap();
11826        assert_eq!(state.lifetime_restarts, 1);
11827        assert_eq!(state.crash_restarts.len(), 0);
11828    }
11829
11830    #[tokio::test]
11831    async fn genuine_crash_spends_restart_budget_and_records_lifetime() {
11832        let supervisor = Supervisor::new_for_test(
11833            Arc::new(Registry::default()),
11834            RestartPolicy::new(3, Duration::ZERO),
11835        );
11836        let runtime = supervisor.runtime_config();
11837        let spec = ModuleSpec {
11838            module_id: "genuine-crash".to_string(),
11839            program: PathBuf::from("/unused/genuine-crash"),
11840            args: Vec::new(),
11841            env: Vec::new(),
11842            reserved: false,
11843            reserved_prefixes: Vec::new(),
11844            protocol: ModuleProtocol::Subc,
11845            overlap: Default::default(),
11846        };
11847        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11848
11849        assert!(matches!(
11850            on_child_exit(
11851                &spec,
11852                runtime.restart_policy,
11853                &supervisor.registry,
11854                &snapshot,
11855                &runtime.terminal_ring,
11856                &runtime.spawn_events,
11857                &runtime.child_roster,
11858                ExitReport {
11859                    kind: ExitKind::Crash,
11860                    code: Some(1),
11861                    signal: None,
11862                    at_ms: 1,
11863                },
11864            )
11865            .await,
11866            NextAction::Restart { schedule: _ }
11867        ));
11868        let state = lock_snapshot(&snapshot).unwrap();
11869        assert_eq!(state.lifetime_restarts, 1);
11870        assert_eq!(state.crash_restarts.len(), 1);
11871    }
11872
11873    fn crash_exit_report(at_ms: u64) -> ExitReport {
11874        ExitReport {
11875            kind: ExitKind::Crash,
11876            code: Some(1),
11877            signal: None,
11878            at_ms,
11879        }
11880    }
11881
11882    fn windowed_crash_spec(module_id: &str) -> ModuleSpec {
11883        ModuleSpec {
11884            module_id: module_id.to_string(),
11885            program: PathBuf::from("/unused").join(module_id),
11886            args: Vec::new(),
11887            env: Vec::new(),
11888            reserved: false,
11889            reserved_prefixes: Vec::new(),
11890            protocol: ModuleProtocol::Subc,
11891            overlap: Default::default(),
11892        }
11893    }
11894
11895    /// A real crash loop still stops. Three crashes with nothing aging out spend
11896    /// a budget of two and the third respawn is refused, and both surfaces an
11897    /// operator has -- the log line and the retained terminal record -- name the
11898    /// window rather than only the cap, because `max_restarts=2` alone is what
11899    /// this budget used to mean.
11900    #[tokio::test]
11901    async fn three_crashes_inside_the_window_stop_the_module_and_name_the_window() {
11902        let (logs, _guard) = crate::router::test_log::log_capture(tracing::Level::ERROR);
11903        let supervisor = Supervisor::new_for_test(
11904            Arc::new(Registry::default()),
11905            RestartPolicy::new(2, Duration::ZERO),
11906        );
11907        let runtime = supervisor.runtime_config();
11908        let spec = windowed_crash_spec("crash-loop-in-window");
11909        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11910
11911        for attempt in 1..=2 {
11912            assert!(
11913                matches!(
11914                    on_child_exit(
11915                        &spec,
11916                        runtime.restart_policy,
11917                        &supervisor.registry,
11918                        &snapshot,
11919                        &runtime.terminal_ring,
11920                        &runtime.spawn_events,
11921                        &runtime.child_roster,
11922                        crash_exit_report(attempt),
11923                    )
11924                    .await,
11925                    NextAction::Restart { schedule: _ }
11926                ),
11927                "crash {attempt} is inside the budget and must respawn"
11928            );
11929        }
11930
11931        assert!(matches!(
11932            on_child_exit(
11933                &spec,
11934                runtime.restart_policy,
11935                &supervisor.registry,
11936                &snapshot,
11937                &runtime.terminal_ring,
11938                &runtime.spawn_events,
11939                &runtime.child_roster,
11940                crash_exit_report(3),
11941            )
11942            .await,
11943            NextAction::Stop { .. }
11944        ));
11945
11946        {
11947            let state = lock_snapshot(&snapshot).unwrap();
11948            assert_eq!(state.state, ModuleState::Failed);
11949            assert_eq!(state.crash_restarts.len(), 2);
11950            assert_eq!(state.lifetime_restarts, 2);
11951        }
11952
11953        let history = runtime
11954            .terminal_ring
11955            .lock()
11956            .expect("terminal ring is not poisoned")
11957            .snapshot();
11958        let last = history
11959            .entries
11960            .last()
11961            .expect("the refused crash is retained");
11962        assert_eq!(last.disposition, TerminalDisposition::Failed);
11963        assert_eq!(
11964            last.disposition_detail.as_deref(),
11965            Some("crash budget exhausted: max_restarts=2 within window_secs=600")
11966        );
11967
11968        let captured = crate::router::test_log::captured_logs(&logs);
11969        assert!(
11970            captured.contains("crash budget exhausted: max_restarts=2 within window_secs=600"),
11971            "the stop must be logged with its window: {captured}"
11972        );
11973    }
11974
11975    /// The rate, stated as a test: three crashes where the first has aged past
11976    /// the window are two crashes as far as the budget is concerned, so the
11977    /// third respawn is allowed and the ring holds only the two recent ones.
11978    ///
11979    /// This is the case a lifetime counter got wrong -- and the case the daemon
11980    /// now hits routinely, since a module exits non-zero every time its
11981    /// connection to the daemon drops.
11982    #[tokio::test]
11983    async fn a_crash_older_than_the_window_frees_its_slot_for_a_later_crash() {
11984        let supervisor = Supervisor::new_for_test(
11985            Arc::new(Registry::default()),
11986            RestartPolicy::new(2, Duration::ZERO),
11987        );
11988        let runtime = supervisor.runtime_config();
11989        let spec = windowed_crash_spec("crash-across-windows");
11990        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11991
11992        for attempt in 1..=2 {
11993            assert!(matches!(
11994                on_child_exit(
11995                    &spec,
11996                    runtime.restart_policy,
11997                    &supervisor.registry,
11998                    &snapshot,
11999                    &runtime.terminal_ring,
12000                    &runtime.spawn_events,
12001                    &runtime.child_roster,
12002                    crash_exit_report(attempt),
12003                )
12004                .await,
12005                NextAction::Restart { schedule: _ }
12006            ));
12007        }
12008
12009        // The oldest crash moves out of the window; nothing else about the
12010        // module changes.
12011        update_snapshot(&snapshot, Some(&spec.module_id), |state| {
12012            age_oldest_crash_restart_out_of_window(state, runtime.restart_policy.window);
12013        })
12014        .unwrap();
12015
12016        assert!(
12017            matches!(
12018                on_child_exit(
12019                    &spec,
12020                    runtime.restart_policy,
12021                    &supervisor.registry,
12022                    &snapshot,
12023                    &runtime.terminal_ring,
12024                    &runtime.spawn_events,
12025                    &runtime.child_roster,
12026                    crash_exit_report(3),
12027                )
12028                .await,
12029                NextAction::Restart { schedule: _ }
12030            ),
12031            "a crash older than the window must not hold a budget slot"
12032        );
12033
12034        let state = lock_snapshot(&snapshot).unwrap();
12035        assert_eq!(state.state, ModuleState::Restarting);
12036        assert_eq!(
12037            state.crash_restarts.len(),
12038            2,
12039            "the aged instant is dropped and the new one takes its place"
12040        );
12041        assert_eq!(
12042            state.lifetime_restarts, 3,
12043            "the ledger counts every restart, including the ones the window forgot"
12044        );
12045    }
12046
12047    /// An operator restart hands the budget back whole, and the ledger keeps
12048    /// counting. Those are different questions -- "how close is this module to
12049    /// being stopped" and "how many times has it been replaced" -- and the
12050    /// operator action answers only the first.
12051    #[tokio::test]
12052    async fn an_operator_restart_clears_the_ring_and_leaves_the_ledger_alone() {
12053        let supervisor = Supervisor::new_for_test(
12054            Arc::new(Registry::default()),
12055            RestartPolicy::new(2, Duration::ZERO),
12056        );
12057        let runtime = supervisor.runtime_config();
12058        let spec = windowed_crash_spec("operator-cleared-budget");
12059        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
12060
12061        for attempt in 1..=2 {
12062            assert!(matches!(
12063                on_child_exit(
12064                    &spec,
12065                    runtime.restart_policy,
12066                    &supervisor.registry,
12067                    &snapshot,
12068                    &runtime.terminal_ring,
12069                    &runtime.spawn_events,
12070                    &runtime.child_roster,
12071                    crash_exit_report(attempt),
12072                )
12073                .await,
12074                NextAction::Restart { schedule: _ }
12075            ));
12076        }
12077
12078        reset_restart_count(&snapshot, &spec.module_id).unwrap();
12079        {
12080            let state = lock_snapshot(&snapshot).unwrap();
12081            assert!(
12082                state.crash_restarts.is_empty(),
12083                "an operator restart returns the full budget"
12084            );
12085            assert_eq!(
12086                state.lifetime_restarts, 2,
12087                "clearing the budget must not unmake the crashes"
12088            );
12089        }
12090
12091        assert!(
12092            matches!(
12093                on_child_exit(
12094                    &spec,
12095                    runtime.restart_policy,
12096                    &supervisor.registry,
12097                    &snapshot,
12098                    &runtime.terminal_ring,
12099                    &runtime.spawn_events,
12100                    &runtime.child_roster,
12101                    crash_exit_report(3),
12102                )
12103                .await,
12104                NextAction::Restart { schedule: _ }
12105            ),
12106            "the cleared budget must be spendable again"
12107        );
12108        let state = lock_snapshot(&snapshot).unwrap();
12109        assert_eq!(state.crash_restarts.len(), 1);
12110        assert_eq!(state.lifetime_restarts, 3);
12111    }
12112
12113    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
12114    async fn severance_marker_for_a_dead_child_does_not_label_its_successor() {
12115        let severed = ProcessIdentity {
12116            pid: 41,
12117            start_time: 101,
12118        };
12119        let successor = ProcessIdentity {
12120            pid: 41,
12121            start_time: 202,
12122        };
12123        let module = module_with_recovery_snapshot(ModuleState::Running, true, 0);
12124        update_snapshot(&module.inner.snapshot, Some("recovery-snapshot"), |state| {
12125            state.pid = Some(successor.pid);
12126            state.process_start_time = Some(successor.start_time);
12127        })
12128        .unwrap();
12129        assert!(!module.record_deliberate_severance(severed).unwrap());
12130
12131        let exit_report = apply_deliberate_severance_marker(
12132            &module.inner.snapshot,
12133            Some(successor),
12134            ExitReport {
12135                kind: ExitKind::Crash,
12136                code: Some(1),
12137                signal: None,
12138                at_ms: 1,
12139            },
12140        );
12141
12142        assert_eq!(exit_report.kind, ExitKind::Crash);
12143    }
12144
12145    #[tokio::test]
12146    async fn drain_reap_marks_deliberate_severance_and_records_lifetime_without_budget() {
12147        let registry = Registry::default();
12148        let supervisor = Supervisor::new_for_test(
12149            Arc::new(Registry::default()),
12150            RestartPolicy::new(3, Duration::ZERO),
12151        );
12152        let runtime = supervisor.runtime_config();
12153        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
12154        let spec = ModuleSpec {
12155            module_id: "drain-deliberate-severance".to_string(),
12156            program: fake_aft_stub_path(),
12157            args: Vec::new(),
12158            env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
12159            reserved: false,
12160            reserved_prefixes: Vec::new(),
12161            protocol: ModuleProtocol::Subc,
12162            overlap: Default::default(),
12163        };
12164        let mut child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
12165        let process = ProcessIdentity {
12166            pid: 41,
12167            start_time: 101,
12168        };
12169        child.process_identity = Some(process);
12170        update_snapshot(&snapshot, Some(&spec.module_id), |state| {
12171            state.pid = Some(process.pid);
12172            state.process_start_time = Some(process.start_time);
12173        })
12174        .unwrap();
12175        record_deliberate_severance(&snapshot, process).unwrap();
12176
12177        drain_child_to_state(
12178            &spec.module_id,
12179            spec.protocol,
12180            // The child exits on its own; no signal may change the exit this
12181            // test classifies.
12182            StopNotice::SentOverConnection,
12183            &registry,
12184            None,
12185            &snapshot,
12186            &runtime.terminal_ring,
12187            &runtime.spawn_events,
12188            child,
12189            Duration::from_secs(1),
12190            ModuleState::Stopped,
12191            Some(false),
12192        )
12193        .await
12194        .unwrap();
12195
12196        let state = lock_snapshot(&snapshot).unwrap();
12197        assert_eq!(
12198            state.last_exit.as_ref().map(|exit| exit.kind),
12199            Some(ExitKind::DeliberateSeverance)
12200        );
12201        assert_eq!(state.lifetime_restarts, 1);
12202        assert_eq!(state.crash_restarts.len(), 0);
12203        drop(state);
12204        let history = runtime.terminal_ring.lock().unwrap().snapshot();
12205        assert_eq!(
12206            history.entries[0].exit_kind,
12207            subc_control::TerminalExitKind::DeliberateSeverance
12208        );
12209    }
12210
12211    #[tokio::test]
12212    async fn ordinary_drain_reap_does_not_record_a_lifetime_restart() {
12213        let registry = Registry::default();
12214        let supervisor = Supervisor::new_for_test(
12215            Arc::new(Registry::default()),
12216            RestartPolicy::new(3, Duration::ZERO),
12217        );
12218        let runtime = supervisor.runtime_config();
12219        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
12220        let spec = ModuleSpec {
12221            module_id: "ordinary-drain".to_string(),
12222            program: fake_aft_stub_path(),
12223            args: Vec::new(),
12224            env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
12225            reserved: false,
12226            reserved_prefixes: Vec::new(),
12227            protocol: ModuleProtocol::Subc,
12228            overlap: Default::default(),
12229        };
12230        let child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
12231
12232        drain_child_to_state(
12233            &spec.module_id,
12234            spec.protocol,
12235            // The child exits on its own; no signal may change the exit this
12236            // test classifies.
12237            StopNotice::SentOverConnection,
12238            &registry,
12239            None,
12240            &snapshot,
12241            &runtime.terminal_ring,
12242            &runtime.spawn_events,
12243            child,
12244            Duration::from_secs(1),
12245            ModuleState::Stopped,
12246            Some(false),
12247        )
12248        .await
12249        .unwrap();
12250
12251        let state = lock_snapshot(&snapshot).unwrap();
12252        assert_eq!(
12253            state.last_exit.as_ref().map(|exit| exit.kind),
12254            Some(ExitKind::Crash)
12255        );
12256        assert_eq!(state.lifetime_restarts, 0);
12257        assert_eq!(state.crash_restarts.len(), 0);
12258    }
12259
12260    #[test]
12261    fn fatal_connection_teardown_cannot_arm_a_marker_for_a_surviving_process() {
12262        // The server's generic fatal-routing branch only knows that the
12263        // connection failed; it does not know that the daemon deliberately
12264        // initiated a process-killing severance. Keep this seam explicit so a
12265        // future connection error path cannot silently reintroduce the stale
12266        // exemption that mislabels a later genuine crash.
12267        assert!(!include_str!("server.rs")
12268            .contains("router.record_deliberate_connection_severance(ctx.connection_id)"));
12269    }
12270
12271    /// The `route.closed` `drained` value must be the quiescence wait's own
12272    /// measurement (`Ok`), never invented -- except on `Err`, where there is no
12273    /// measurement at all and `false` is the one honest constant. This is the exact
12274    /// logic `begin_forwarding_drain_with` now applies before sending `route.closed`
12275    /// on every return path, including the one that used to return early via `?`
12276    /// with `route.closing` already sent and no `route.closed` ever following.
12277    #[test]
12278    fn drained_after_quiescence_wait_passes_ok_through_and_forces_false_on_err() {
12279        assert!(drained_after_quiescence_wait(&Ok(true)));
12280        assert!(!drained_after_quiescence_wait(&Ok(false)));
12281        assert!(!drained_after_quiescence_wait(&Err(
12282            SuperviseError::StatePoisoned { module_id: None }
12283        )));
12284    }
12285
12286    /// `supervise_loop`'s `wait()`-error arm now calls `record_terminal` like every
12287    /// other exit path does, so a module whose child `wait()` itself errored (e.g.
12288    /// already reaped out-of-band) still leaves a terminal record rather than none
12289    /// at all. Triggering the real `wait()` I/O error from an integration test would
12290    /// need a genuine already-reaped-child race, which is OS-specific and not
12291    /// something this suite attempts elsewhere; this test instead verifies the
12292    /// record produced for that arm end-to-end through the real `TerminalRing`, and
12293    /// the call site itself is verified by inspection to sit in that exact arm.
12294    #[test]
12295    fn wait_error_exit_report_records_a_failed_terminal_with_no_code_or_signal() {
12296        let ring = Arc::new(Mutex::new(TerminalRing::new(
12297            TerminalRingConfig::default(),
12298            0,
12299        )));
12300        record_wait_error_terminal("wait-error", &ring, &super::SpawnEventFeed::default());
12301
12302        let snapshot = ring.lock().unwrap().snapshot();
12303        assert_eq!(snapshot.entries.len(), 1);
12304        let entry = &snapshot.entries[0];
12305        assert_eq!(entry.exit_code, None);
12306        assert_eq!(entry.exit_signal, None);
12307        assert_eq!(entry.disposition, TerminalDisposition::Failed);
12308    }
12309
12310    #[test]
12311    fn wait_error_exit_path_preserves_spawn_event_density() {
12312        let feed = super::SpawnEventFeed::default();
12313        feed.configure_incarnation("wait-error-density".to_string());
12314        feed.emit_spawned("wait-error", 41, 1);
12315        let ring = Arc::new(Mutex::new(TerminalRing::new(
12316            TerminalRingConfig::default(),
12317            0,
12318        )));
12319
12320        record_wait_error_terminal("wait-error", &ring, &feed);
12321        feed.emit_spawned("after-wait-error", 42, 2);
12322
12323        let state = feed.0.lock().unwrap();
12324        let sequences = state
12325            .events
12326            .iter()
12327            .map(|event| event.cursor.seq)
12328            .collect::<Vec<_>>();
12329        assert_eq!(sequences, vec![1, 2, 3]);
12330        assert_eq!(state.events[1].kind, SpawnEventKind::Exited);
12331        assert_eq!(state.events[1].exit_code, None);
12332        assert_eq!(state.events[1].exit_signal, None);
12333    }
12334
12335    /// Pins the report's `kind` too: the wait-error arm treats an unwaitable child
12336    /// as a crash (matching `fail_snapshot`'s `Failed` disposition for this arm),
12337    /// not a clean exit it never actually observed.
12338    #[test]
12339    fn wait_error_exit_report_is_classified_as_a_crash() {
12340        assert_eq!(wait_error_exit_report().kind, ExitKind::Crash);
12341    }
12342}
12343
12344#[cfg(all(test, unix))]
12345mod health_event_tests {
12346    use super::*;
12347    use cortexkit_test_support::ScratchDir;
12348    use subc_protocol::{manifest::Concurrency, session::ModuleControlResponse};
12349
12350    struct Actor {
12351        module: SupervisedModule,
12352        registry: Arc<Registry>,
12353        forwarding: Arc<ForwardingTable>,
12354        spec: ModuleSpec,
12355        health: HealthConfig,
12356        rx: mpsc::Receiver<crate::router::OutboundFrame>,
12357        _home: ScratchDir,
12358    }
12359
12360    async fn wait_for<T>(reason: &str, mut observe: impl FnMut() -> Option<T>) -> T {
12361        // OS exec and socket readiness are real I/O. Stay runnable while waiting
12362        // for an observed condition, rather than letting paused time auto-advance
12363        // deadlines or assuming a fixed number of yields finishes the work.
12364        let deadline = std::time::Instant::now() + Duration::from_secs(10);
12365        let tick = Instant::now();
12366        loop {
12367            if let Some(value) = observe() {
12368                assert_eq!(Instant::now(), tick, "{reason} must not wait for a timer");
12369                return value;
12370            }
12371            assert!(
12372                std::time::Instant::now() < deadline,
12373                "timed out waiting for {reason}"
12374            );
12375            tokio::task::yield_now().await;
12376        }
12377    }
12378
12379    impl Actor {
12380        async fn start(protocol: ModuleProtocol, cadence: Duration) -> Self {
12381            let home = ScratchDir::new("health-events");
12382            let registry = Arc::new(Registry::default());
12383            let forwarding = Arc::new(ForwardingTable::default());
12384            let health = HealthConfig {
12385                cadence,
12386                deadline: Duration::from_secs(3600),
12387                ..HealthConfig::default()
12388            };
12389            let supervisor =
12390                Supervisor::new_for_test(registry.clone(), RestartPolicy::new(3, Duration::ZERO))
12391                    .with_forwarding(forwarding.clone())
12392                    .with_health_config(health.clone());
12393            let spec = ModuleSpec {
12394                module_id: "event-health-child".into(),
12395                program: PathBuf::from("/bin/sh"),
12396                // The process stays alive but has no real bus peer. Registrations
12397                // below exercise the same registry writes as accepted HELLOs.
12398                args: vec!["-c".into(), "exec sleep 600".into()],
12399                env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
12400                    .into_iter()
12401                    .map(|key| (key.into(), home.path().display().to_string()))
12402                    .collect(),
12403                reserved: false,
12404                reserved_prefixes: vec![],
12405                protocol,
12406                overlap: Default::default(),
12407            };
12408            let module = supervisor.spawn(spec.clone()).unwrap();
12409            let (_, rx) = mpsc::channel(8);
12410            let actor = Self {
12411                module,
12412                registry,
12413                forwarding,
12414                spec,
12415                health,
12416                rx,
12417                _home: home,
12418            };
12419            actor
12420                .wait_for_select(|checkpoint| checkpoint.generation > 0)
12421                .await;
12422            actor
12423        }
12424
12425        fn turns(&self) -> u64 {
12426            lock_snapshot(&self.module.inner.snapshot)
12427                .unwrap()
12428                .actor_turns
12429        }
12430
12431        async fn wait_for_select(
12432            &self,
12433            expected: impl Fn(&ActorSelectCheckpoint) -> bool,
12434        ) -> ActorSelectCheckpoint {
12435            wait_for("actor select checkpoint", || {
12436                let state = lock_snapshot(&self.module.inner.snapshot).unwrap();
12437                state.actor_select.filter(|checkpoint| {
12438                    checkpoint.generation == state.spawn_generation
12439                        && state.state == ModuleState::Running
12440                        && state.process_alive
12441                        && state.reported_pid().is_some()
12442                        && expected(checkpoint)
12443                })
12444            })
12445            .await
12446        }
12447
12448        fn hello(&mut self, connection: u64, health: bool) {
12449            let manifest =
12450                subc_protocol::manifest::ModuleManifest::builder(&self.spec.module_id, "1.0.0")
12451                    .protocol_ver(subc_protocol::PROTOCOL_VERSION)
12452                    .build();
12453            let connection = ConnectionId::new(connection);
12454            let (tx, rx) = mpsc::channel(8);
12455            self.rx = rx;
12456            self.forwarding
12457                .register_module_connection(
12458                    connection,
12459                    self.spec.module_id.clone(),
12460                    subc_protocol::PROTOCOL_VERSION,
12461                    Concurrency::ModuleManaged,
12462                    FrameSink::new(tx),
12463                )
12464                .unwrap();
12465            self.registry
12466                .register_with_control_ops(
12467                    manifest,
12468                    subc_protocol::PROTOCOL_VERSION,
12469                    connection,
12470                    if health {
12471                        vec![MODULE_CONTROL_OP_HEALTH_CHECK.into()]
12472                    } else {
12473                        vec![]
12474                    },
12475                )
12476                .unwrap();
12477        }
12478
12479        async fn probe(&mut self) -> crate::router::OutboundFrame {
12480            let frame = wait_for("outbound health probe", || match self.rx.try_recv() {
12481                Ok(frame) => Some(frame),
12482                Err(mpsc::error::TryRecvError::Empty) => None,
12483                Err(mpsc::error::TryRecvError::Disconnected) => panic!("health peer disconnected"),
12484            })
12485            .await;
12486            let body: Value = serde_json::from_slice(&frame.body).unwrap();
12487            assert_eq!(body["op"], MODULE_CONTROL_OP_HEALTH_CHECK);
12488            frame
12489        }
12490
12491        fn no_probe(&mut self) {
12492            assert!(matches!(
12493                self.rx.try_recv(),
12494                Err(mpsc::error::TryRecvError::Empty)
12495            ));
12496        }
12497
12498        fn answer(&self, connection: u64, frame: crate::router::OutboundFrame) {
12499            self.forwarding
12500                .complete_module_control_rpc(
12501                    ConnectionId::new(connection),
12502                    frame.header.corr,
12503                    Some(MODULE_CONTROL_OP_HEALTH_CHECK),
12504                    ModuleControlRpcOutcome::Response(ModuleControlResponse::HealthCheck {
12505                        status: HealthStatus::Ok,
12506                        detail: None,
12507                        metrics: None,
12508                    }),
12509                )
12510                .unwrap();
12511        }
12512    }
12513
12514    #[tokio::test(start_paused = true)]
12515    async fn no_health_children_park_for_sixty_seconds() {
12516        let none = Actor::start(ModuleProtocol::None, Duration::from_secs(30)).await;
12517        let unregistered = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12518        let mut unadvertised = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12519        unadvertised.hello(1, false);
12520        // Include any one-off registration wake in the budget; receiving a
12521        // no-health HELLO must not turn a parked child into a polling child.
12522        // Advance in 10 ms steps so the old poll really executes ~6,000 turns;
12523        // a single 60 s jump would only observe one expired timer.
12524        for _ in 0..6000 {
12525            tokio::time::advance(Duration::from_millis(10)).await;
12526            tokio::task::yield_now().await;
12527        }
12528        for actor in [&none, &unregistered, &unadvertised] {
12529            assert!(
12530                actor.turns() <= 5,
12531                "no-health actor woke {} times",
12532                actor.turns()
12533            );
12534            assert_eq!(actor.module.state().unwrap(), ModuleState::Running);
12535        }
12536    }
12537
12538    #[tokio::test(start_paused = true)]
12539    async fn late_hello_starts_a_due_probe_in_the_notification_tick() {
12540        // With a zero cadence the first probe is due at once, so this measures
12541        // only how fast the registration notification starts health probing,
12542        // not the normal 30-33 s wait before a first probe (checked below).
12543        let mut actor = Actor::start(ModuleProtocol::Subc, Duration::ZERO).await;
12544        let tick = Instant::now();
12545        actor.hello(1, true);
12546        actor.probe().await;
12547        assert_eq!(Instant::now(), tick, "HELLO must not wait for a poll");
12548    }
12549
12550    #[tokio::test(start_paused = true)]
12551    async fn hello_keeps_the_jittered_cadence_and_catalog_updates_do_not_reset_it() {
12552        let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12553        let hello_tick = Instant::now();
12554        actor.hello(1, true);
12555        let armed = 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        let delay = jittered_health_delay(&actor.spec.module_id, 0, actor.health.cadence);
12562        assert!((Duration::from_secs(30)..Duration::from_secs(33)).contains(&delay));
12563        let first_deadline = hello_tick + delay;
12564        assert_eq!(armed.next_probe_at, Some(first_deadline));
12565        tokio::time::advance(Duration::from_secs(29)).await;
12566        let turns = actor.turns();
12567        actor
12568            .registry
12569            .replace_catalog_for_connection(ConnectionId::new(1), vec![], None, Some(true))
12570            .unwrap();
12571        let updated = actor
12572            .wait_for_select(|checkpoint| checkpoint.turn > turns)
12573            .await;
12574        assert_eq!(
12575            updated.next_probe_at,
12576            Some(first_deadline),
12577            "catalog update must not reset cadence"
12578        );
12579        actor.no_probe();
12580        tokio::time::advance(first_deadline - Instant::now()).await;
12581        let frame = actor.probe().await;
12582        actor.answer(1, frame);
12583        let next = jittered_health_delay(&actor.spec.module_id, 1, actor.health.cadence);
12584        let next_deadline = Instant::now() + next;
12585        let rearmed = actor
12586            .wait_for_select(|checkpoint| {
12587                checkpoint
12588                    .next_probe_at
12589                    .is_some_and(|deadline| deadline > first_deadline)
12590            })
12591            .await;
12592        assert_eq!(rearmed.next_probe_at, Some(next_deadline));
12593        tokio::time::advance(next - Duration::from_nanos(1)).await;
12594        actor.no_probe();
12595        tokio::time::advance(Duration::from_nanos(1)).await;
12596        actor.probe().await;
12597    }
12598
12599    #[tokio::test(start_paused = true)]
12600    async fn disconnect_disarms_and_reconnect_rearms_health() {
12601        let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12602        actor.hello(1, true);
12603        actor
12604            .wait_for_select(|checkpoint| {
12605                checkpoint.registered_connection == Some(ConnectionId::new(1))
12606                    && checkpoint.next_probe_at.is_some()
12607            })
12608            .await;
12609        let turns = actor.turns();
12610        let tick = Instant::now();
12611        actor
12612            .registry
12613            .deregister_connection(ConnectionId::new(1))
12614            .unwrap();
12615        let disarmed = actor
12616            .wait_for_select(|checkpoint| {
12617                checkpoint.turn > turns && checkpoint.registered_connection.is_none()
12618            })
12619            .await;
12620        assert_eq!(disarmed.next_probe_at, None);
12621        assert_eq!(disarmed.wake_after, None);
12622        assert_eq!(
12623            Instant::now(),
12624            tick,
12625            "disconnect must disarm in the notification tick"
12626        );
12627        tokio::time::advance(Duration::from_secs(60)).await;
12628        actor.no_probe();
12629        let reconnect_tick = Instant::now();
12630        actor.hello(2, true);
12631        let rearmed = actor
12632            .wait_for_select(|checkpoint| {
12633                checkpoint.registered_connection == Some(ConnectionId::new(2))
12634                    && checkpoint.next_probe_at.is_some()
12635            })
12636            .await;
12637        let delay = jittered_health_delay(&actor.spec.module_id, 0, actor.health.cadence);
12638        assert_eq!(rearmed.next_probe_at, Some(reconnect_tick + delay));
12639        tokio::time::advance(delay).await;
12640        actor.probe().await;
12641    }
12642
12643    #[tokio::test(start_paused = true)]
12644    async fn reregistration_without_health_disarms_the_old_schedule() {
12645        let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12646        actor.hello(1, true);
12647        actor
12648            .wait_for_select(|checkpoint| {
12649                checkpoint.registered_connection == Some(ConnectionId::new(1))
12650                    && checkpoint.next_probe_at.is_some()
12651            })
12652            .await;
12653        actor
12654            .registry
12655            .deregister_connection(ConnectionId::new(1))
12656            .unwrap();
12657        actor.hello(2, false);
12658        let disarmed = actor
12659            .wait_for_select(|checkpoint| {
12660                checkpoint.registered_connection == Some(ConnectionId::new(2))
12661            })
12662            .await;
12663        assert_eq!(disarmed.next_probe_at, None);
12664        assert_eq!(disarmed.wake_after, None);
12665        tokio::time::advance(Duration::from_secs(60)).await;
12666        actor.no_probe();
12667    }
12668
12669    #[tokio::test(start_paused = true)]
12670    async fn restart_waits_for_its_own_hello_before_rearming_health() {
12671        let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12672        actor.hello(1, true);
12673        let armed = actor
12674            .wait_for_select(|checkpoint| {
12675                checkpoint.registered_connection == Some(ConnectionId::new(1))
12676                    && checkpoint.next_probe_at.is_some()
12677            })
12678            .await;
12679        // Simulate the old process's connection closing before the restart
12680        // tears the process down. The replacement process has started but has
12681        // not sent its HELLO, so it is not registered.
12682        actor
12683            .registry
12684            .deregister_connection(ConnectionId::new(1))
12685            .unwrap();
12686        actor.module.restart(Some(0)).await.unwrap();
12687        let parked = actor
12688            .wait_for_select(|checkpoint| {
12689                checkpoint.generation > armed.generation
12690                    && checkpoint.registered_connection.is_none()
12691                    && checkpoint.next_probe_at.is_none()
12692            })
12693            .await;
12694        assert_eq!(parked.wake_after, None);
12695        actor.rx.close();
12696        // Drain the stop notice the restart queued for the old process's connection,
12697        // so it isn't mistaken for traffic to the replacement.
12698        while actor.rx.try_recv().is_ok() {}
12699        let turns = actor.turns();
12700        tokio::time::advance(Duration::from_secs(60)).await;
12701        let after = actor.turns();
12702        eprintln!(
12703            "restart park turns: before={turns} after={after} delta={}",
12704            after - turns
12705        );
12706        assert_eq!(after, turns, "replacement must park before HELLO");
12707        let hello_tick = Instant::now();
12708        actor.hello(2, true);
12709        let rearmed = actor
12710            .wait_for_select(|checkpoint| {
12711                checkpoint.registered_connection == Some(ConnectionId::new(2))
12712                    && checkpoint.next_probe_at.is_some()
12713            })
12714            .await;
12715        let delay = jittered_health_delay(&actor.spec.module_id, 0, actor.health.cadence);
12716        assert_eq!(rearmed.next_probe_at, Some(hello_tick + delay));
12717        tokio::time::advance(delay).await;
12718        actor.probe().await;
12719    }
12720
12721    #[tokio::test(start_paused = true)]
12722    async fn rescan_adds_and_removes_http_health_without_polling() {
12723        use tokio::{
12724            io::{AsyncReadExt, AsyncWriteExt},
12725            net::TcpListener,
12726        };
12727        let mut actor = Actor::start(ModuleProtocol::None, Duration::from_secs(30)).await;
12728        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
12729        actor.health.http = Some(format!("http://{}/health", listener.local_addr().unwrap()));
12730        // One millisecond separates successive probes without a polling delay.
12731        actor.health.cadence = Duration::from_millis(1);
12732        let (started_tx, mut started_rx) = mpsc::channel(8);
12733        let peer = tokio::spawn(async move {
12734            loop {
12735                let (mut socket, _) = listener.accept().await.unwrap();
12736                let mut request = [0; 1024];
12737                assert!(socket.read(&mut request).await.unwrap() > 0);
12738                started_tx.send(()).await.unwrap();
12739                socket
12740                    .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
12741                    .await
12742                    .unwrap();
12743            }
12744        });
12745        let tick = Instant::now();
12746        let turns = actor.turns();
12747        actor
12748            .module
12749            .update_configuration(actor.spec.clone(), actor.health.clone(), None)
12750            .await
12751            .unwrap();
12752        let armed = actor
12753            .wait_for_select(|checkpoint| {
12754                checkpoint.turn > turns && checkpoint.next_probe_at.is_some()
12755            })
12756            .await;
12757        assert_eq!(armed.next_probe_at, Some(tick + Duration::from_millis(1)));
12758        assert_eq!(Instant::now(), tick);
12759        tokio::time::advance(Duration::from_millis(1)).await;
12760        let mut started = false;
12761        wait_for("successful HTTP probe", || {
12762            started |= started_rx.try_recv().is_ok();
12763            (started && actor.module.status().unwrap().health.status == SupervisorHealthStatus::Ok)
12764                .then_some(())
12765        })
12766        .await;
12767        assert!(started, "added HTTP check must start at its first deadline");
12768        assert_eq!(
12769            actor.module.status().unwrap().health.status,
12770            SupervisorHealthStatus::Ok
12771        );
12772        assert_eq!(Instant::now(), tick + Duration::from_millis(1));
12773        actor.health.http = None;
12774        let turns = actor.turns();
12775        actor
12776            .module
12777            .update_configuration(actor.spec.clone(), actor.health.clone(), None)
12778            .await
12779            .unwrap();
12780        let parked = actor
12781            .wait_for_select(|checkpoint| {
12782                checkpoint.turn > turns && checkpoint.next_probe_at.is_none()
12783            })
12784            .await;
12785        assert_eq!(parked.wake_after, None);
12786        let turns = actor.turns();
12787        tokio::time::advance(Duration::from_secs(60)).await;
12788        assert_eq!(
12789            actor.turns(),
12790            turns,
12791            "removed HTTP check must park the actor"
12792        );
12793        assert!(
12794            started_rx.try_recv().is_err(),
12795            "removed HTTP check must stay disarmed"
12796        );
12797        assert_eq!(
12798            actor.module.status().unwrap().health,
12799            ModuleHealthStatus::default()
12800        );
12801        peer.abort();
12802    }
12803}
12804
12805#[cfg(test)]
12806mod health_evidence_tests {
12807    use super::{HealthProbeError, HealthProbeEvidence};
12808    use std::collections::HashSet;
12809
12810    /// The evidential asymmetry, asserted rather than described.
12811    ///
12812    /// Exactly ONE observation is proof a module cannot serve, and the one that
12813    /// fires under CPU starvation is not it. Before the split, all fifteen
12814    /// construction sites collapsed into a single String, so a timeout carried the
12815    /// same weight as a dead lane -- which is how a healthy module was restarted
12816    /// three times in one day.
12817    #[test]
12818    fn only_a_dead_lane_is_proof_of_death() {
12819        assert!(HealthProbeError::lane_dead("gone").is_proof_of_death());
12820        // Three non-proof classes, each for a different reason: silence is
12821        // consistent with health, a bad answer proves the module ALIVE, and a
12822        // daemon-side fault never reached the module at all.
12823        assert!(!HealthProbeError::no_answer("timed out").is_proof_of_death());
12824        assert!(!HealthProbeError::bad_answer("garbage").is_proof_of_death());
12825        assert!(!HealthProbeError::misconfigured("no table").is_proof_of_death());
12826    }
12827
12828    /// Labels must be distinct, or the operator-facing distinction is cosmetic.
12829    ///
12830    /// A shared label renders two different observations identically in the line an
12831    /// operator reads after an unexplained restart -- the exact confusion this
12832    /// change removes.
12833    #[test]
12834    fn every_evidence_class_has_a_distinct_label() {
12835        let labels = [
12836            HealthProbeError::lane_dead("").label(),
12837            HealthProbeError::no_answer("").label(),
12838            HealthProbeError::bad_answer("").label(),
12839            HealthProbeError::misconfigured("").label(),
12840        ];
12841        let unique: HashSet<_> = labels.iter().collect();
12842        assert_eq!(unique.len(), labels.len(), "labels collided: {labels:?}");
12843    }
12844
12845    /// The class is additional information, not a replacement.
12846    ///
12847    /// An operator needs both "this was silence" and the specific text saying how
12848    /// long we waited; a classification that swallowed the message would trade one
12849    /// missing distinction for another.
12850    #[test]
12851    fn classification_preserves_the_original_message() {
12852        let err = HealthProbeError::no_answer("module did not answer within 5s");
12853        assert_eq!(err.to_string(), "module did not answer within 5s");
12854        assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
12855    }
12856}
12857
12858#[cfg(test)]
12859mod health_tombstone_tests {
12860    use std::{path::PathBuf, sync::Arc, time::Duration};
12861
12862    use subc_protocol::{
12863        manifest::Concurrency,
12864        session::{HealthStatus, ModuleControlResponse},
12865    };
12866    use tokio::sync::mpsc;
12867
12868    use super::{
12869        probe_module_health, HealthAction, HealthConfig, HealthProbeEvidence, ModuleProtocol,
12870        ModuleSpec, RestartPolicy, Supervisor, SupervisorRuntimeConfig,
12871    };
12872    use crate::{
12873        control::ControlHandler,
12874        forwarding::{ForwardingTable, ModuleControlRpcCompletion, ModuleControlRpcOutcome},
12875        registry::{ConnectionId, Registry},
12876        router::FrameSink,
12877    };
12878
12879    struct ProbeHarness {
12880        spec: ModuleSpec,
12881        runtime: SupervisorRuntimeConfig,
12882        forwarding: Arc<ForwardingTable>,
12883        module_connection: ConnectionId,
12884        module_rx: mpsc::Receiver<crate::router::OutboundFrame>,
12885        handler: ControlHandler,
12886        module: super::SupervisedModule,
12887    }
12888
12889    fn probe_harness() -> ProbeHarness {
12890        let registry = Arc::new(Registry::default());
12891        let forwarding = Arc::new(ForwardingTable::default());
12892        let supervisor_handle = super::SupervisorHandle::new();
12893        let health = HealthConfig {
12894            http: None,
12895            cadence: Duration::from_secs(30),
12896            deadline: Duration::from_secs(5),
12897            failure_threshold: 3,
12898            on_degraded: HealthAction::Report,
12899            on_failing: HealthAction::Report,
12900            critical: false,
12901        };
12902        let supervisor = Supervisor::new_for_test(Arc::clone(&registry), RestartPolicy::default())
12903            .with_forwarding(Arc::clone(&forwarding))
12904            .with_handle(supervisor_handle.clone())
12905            .with_health_config(health);
12906        let spec = ModuleSpec {
12907            module_id: "late-health-module".to_string(),
12908            program: PathBuf::from("disabled-module"),
12909            args: Vec::new(),
12910            env: Vec::new(),
12911            reserved: false,
12912            reserved_prefixes: Vec::new(),
12913            protocol: ModuleProtocol::Subc,
12914            overlap: Default::default(),
12915        };
12916        let module = supervisor
12917            .supervise_configured(spec.clone(), false)
12918            .unwrap();
12919        let runtime = supervisor.runtime_config();
12920        let handler = ControlHandler::with_forwarding(registry, Arc::clone(&forwarding))
12921            .with_supervisor(supervisor_handle);
12922        let module_connection = ConnectionId::new(700);
12923        let (module_tx, module_rx) = mpsc::channel(8);
12924        forwarding
12925            .register_module_connection(
12926                module_connection,
12927                spec.module_id.clone(),
12928                subc_protocol::PROTOCOL_VERSION,
12929                Concurrency::ModuleManaged,
12930                FrameSink::new(module_tx),
12931            )
12932            .unwrap();
12933
12934        ProbeHarness {
12935            spec,
12936            runtime,
12937            forwarding,
12938            module_connection,
12939            module_rx,
12940            handler,
12941            module,
12942        }
12943    }
12944
12945    async fn finish_after(
12946        harness: &mut ProbeHarness,
12947        stall: Duration,
12948    ) -> ModuleControlRpcCompletion {
12949        assert!(stall > harness.runtime.health.deadline);
12950        let deadline = harness.runtime.health.deadline;
12951        let probe = probe_module_health(&harness.spec.module_id, &harness.runtime, None);
12952        let answer = async {
12953            let frame = harness.module_rx.recv().await.expect("health.check frame");
12954            tokio::time::advance(deadline).await;
12955            tokio::task::yield_now().await;
12956            tokio::time::advance(stall - deadline).await;
12957            harness
12958                .forwarding
12959                .complete_module_control_rpc(
12960                    harness.module_connection,
12961                    frame.header.corr,
12962                    Some("health.check"),
12963                    ModuleControlRpcOutcome::Response(ModuleControlResponse::HealthCheck {
12964                        status: HealthStatus::Ok,
12965                        detail: None,
12966                        metrics: None,
12967                    }),
12968                )
12969                .unwrap()
12970        };
12971        let (probe_result, completion) = tokio::join!(probe, answer);
12972        let err = probe_result.expect_err("probe must miss its deadline");
12973        assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
12974        completion
12975    }
12976
12977    async fn time_out_without_answer(harness: &mut ProbeHarness) {
12978        let deadline = harness.runtime.health.deadline;
12979        let probe = probe_module_health(&harness.spec.module_id, &harness.runtime, None);
12980        let exhaust_deadline = async {
12981            let _frame = harness.module_rx.recv().await.expect("health.check frame");
12982            tokio::time::advance(deadline).await;
12983            tokio::task::yield_now().await;
12984        };
12985        let (probe_result, ()) = tokio::join!(probe, exhaust_deadline);
12986        let err = probe_result.expect_err("probe must miss its deadline");
12987        assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
12988    }
12989
12990    async fn run_probe_cycle(harness: &mut ProbeHarness, answer: bool) {
12991        let registry = Arc::clone(&harness.module.inner.registry);
12992        let snapshot = Arc::clone(&harness.module.inner.snapshot);
12993        let process_liveness = super::SupervisorProcessLiveness::default();
12994        let mut child = None;
12995        let cycle = super::run_health_probe_cycle(
12996            &harness.spec,
12997            &harness.runtime,
12998            &registry,
12999            &process_liveness,
13000            &snapshot,
13001            &mut child,
13002        );
13003        let peer = async {
13004            let frame = harness.module_rx.recv().await.expect("health.check frame");
13005            if answer {
13006                harness
13007                    .forwarding
13008                    .complete_module_control_rpc(
13009                        harness.module_connection,
13010                        frame.header.corr,
13011                        Some("health.check"),
13012                        ModuleControlRpcOutcome::Response(ModuleControlResponse::HealthCheck {
13013                            status: HealthStatus::Ok,
13014                            detail: None,
13015                            metrics: Some(serde_json::json!({"ready": true})),
13016                        }),
13017                    )
13018                    .unwrap();
13019            } else {
13020                tokio::time::advance(harness.runtime.health.deadline).await;
13021                tokio::task::yield_now().await;
13022            }
13023        };
13024        tokio::join!(cycle, peer);
13025    }
13026
13027    #[tokio::test(start_paused = true)]
13028    async fn unanswered_probe_is_unknown_until_threshold_and_ok_report_recovers() {
13029        let mut harness = probe_harness();
13030        // Drive the probe cycle directly with an in-memory wire peer. Stop the
13031        // disabled module's monitor so only this test owns lifecycle transitions;
13032        // no OS process is launched, and a restart is observed at scheduling.
13033        let monitor = harness.module.inner.monitor.lock().unwrap().take().unwrap();
13034        monitor.abort();
13035        let _ = monitor.await;
13036        super::update_snapshot(&harness.module.inner.snapshot, None, |state| {
13037            state.enabled = true;
13038            state.state = super::ModuleState::Running;
13039            state.process_alive = true;
13040        })
13041        .unwrap();
13042
13043        run_probe_cycle(&mut harness, true).await;
13044        assert_eq!(
13045            harness.module.status().unwrap().health.status,
13046            super::SupervisorHealthStatus::Ok
13047        );
13048
13049        for failures in 1..harness.runtime.health.failure_threshold {
13050            run_probe_cycle(&mut harness, false).await;
13051            let status = harness.module.status().unwrap();
13052            assert_eq!(status.health.status, super::SupervisorHealthStatus::Unknown);
13053            assert_eq!(status.health.consecutive_failures, failures);
13054            assert!(status.health.last_probe_ms.is_some());
13055            assert!(status.health.detail.unwrap().starts_with("[no-answer]"));
13056            assert!(status.health.metrics.is_none());
13057            assert_eq!(status.state, super::ModuleState::Running);
13058            assert!(status.process_alive);
13059            assert_eq!(status.restart_count, 0);
13060            assert_eq!(status.lifetime_restarts, 0);
13061            assert!(status.health.last_action.is_none());
13062            assert!(harness.runtime.scheduled_respawn.lock().unwrap().is_none());
13063        }
13064
13065        run_probe_cycle(&mut harness, true).await;
13066        let recovered = harness.module.status().unwrap();
13067        assert_eq!(recovered.health.status, super::SupervisorHealthStatus::Ok);
13068        assert_eq!(recovered.health.consecutive_failures, 0);
13069        assert!(recovered.health.detail.is_none());
13070        assert_eq!(
13071            recovered.health.metrics,
13072            Some(serde_json::json!({"ready": true}))
13073        );
13074        assert_eq!(recovered.lifetime_restarts, 0);
13075
13076        for failures in 1..=harness.runtime.health.failure_threshold {
13077            run_probe_cycle(&mut harness, false).await;
13078            let status = harness.module.status().unwrap();
13079            assert_eq!(status.health.consecutive_failures, failures);
13080            if failures < harness.runtime.health.failure_threshold {
13081                assert_eq!(status.health.status, super::SupervisorHealthStatus::Unknown);
13082                assert_eq!(status.state, super::ModuleState::Running);
13083                assert_eq!(status.lifetime_restarts, 0);
13084                assert!(harness.runtime.scheduled_respawn.lock().unwrap().is_none());
13085            } else {
13086                assert_eq!(
13087                    status.health.status,
13088                    super::SupervisorHealthStatus::Unresponsive
13089                );
13090                assert_eq!(status.state, super::ModuleState::Restarting);
13091                assert_eq!(status.restart_count, 1);
13092                assert_eq!(status.lifetime_restarts, 1);
13093                assert_eq!(status.health.last_action.as_deref(), Some("restart"));
13094                assert!(harness.runtime.scheduled_respawn.lock().unwrap().is_some());
13095            }
13096        }
13097    }
13098
13099    #[tokio::test(start_paused = true)]
13100    async fn late_health_answers_record_start_anchored_latency_for_two_stalls() {
13101        let mut harness = probe_harness();
13102
13103        let first = finish_after(&mut harness, Duration::from_secs(8)).await;
13104        let first_latency = match &first {
13105            ModuleControlRpcCompletion::LateHealthAnswer { latency, .. } => *latency,
13106            other => panic!("late answer was not retained: {other:?}"),
13107        };
13108        assert!(harness.handler.observe_module_control_completion(first));
13109
13110        let second = finish_after(&mut harness, Duration::from_secs(11)).await;
13111        let second_latency = match &second {
13112            ModuleControlRpcCompletion::LateHealthAnswer { latency, .. } => *latency,
13113            other => panic!("late answer was not retained: {other:?}"),
13114        };
13115        assert!(harness.handler.observe_module_control_completion(second));
13116
13117        assert_eq!(first_latency, Duration::from_secs(8));
13118        assert_eq!(
13119            second_latency - first_latency,
13120            Duration::from_secs(3),
13121            "latency must grow linearly with the additional stall"
13122        );
13123        let health = harness.module.status().unwrap().health;
13124        assert_eq!(health.late_answer_count, 2);
13125        assert_eq!(health.last_late_answer_latency_ms, Some(11_000));
13126    }
13127
13128    /// A module that answers every probe late must never march to the kill
13129    /// threshold: the late answer proves it is alive, so it must clear the miss
13130    /// streak the timeout recorded. Without the reset, a CPU-starved module
13131    /// that serves every probe seconds past the deadline accumulates
13132    /// `consecutive_failures` to the threshold and is killed — the exact
13133    /// sequence from the 2026-08-14 aft disable, where the daemon logged
13134    /// "proves the module is alive" five times while counting five misses.
13135    #[tokio::test(start_paused = true)]
13136    async fn late_answer_clears_the_consecutive_failure_streak() {
13137        let mut harness = probe_harness();
13138
13139        // Timeout recorded first: the probe path saw no answer in time.
13140        time_out_without_answer(&mut harness).await;
13141        harness
13142            .module
13143            .record_health_probe_failure_for_test("[no-answer] test miss")
13144            .unwrap();
13145        assert_eq!(
13146            harness.module.status().unwrap().health.consecutive_failures,
13147            1,
13148            "precondition: the miss must be on the streak before the late answer"
13149        );
13150
13151        // The stalled reply then lands: proof of life.
13152        let late = finish_after(&mut harness, Duration::from_secs(9)).await;
13153        assert!(matches!(
13154            late,
13155            ModuleControlRpcCompletion::LateHealthAnswer { .. }
13156        ));
13157        assert!(harness.handler.observe_module_control_completion(late));
13158
13159        let health = harness.module.status().unwrap().health;
13160        assert_eq!(
13161            health.consecutive_failures, 0,
13162            "a late answer is an answer: the streak must reset"
13163        );
13164        assert_eq!(health.late_answer_count, 1);
13165    }
13166
13167    #[tokio::test(start_paused = true)]
13168    async fn repeated_serial_probe_cycles_keep_one_tombstone_per_endpoint() {
13169        let mut harness = probe_harness();
13170
13171        for _ in 0..20 {
13172            time_out_without_answer(&mut harness).await;
13173            assert_eq!(
13174                harness.forwarding.health_probe_tombstone_count().unwrap(),
13175                1
13176            );
13177        }
13178    }
13179}
13180
13181#[cfg(test)]
13182mod child_env_tests {
13183    #[cfg(windows)]
13184    use super::{
13185        clear_current_process_facts, observed_launch_nonce_source, ModuleState, SupervisorSnapshot,
13186    };
13187    #[cfg(windows)]
13188    use std::sync::{Arc, OnceLock};
13189    #[cfg(windows)]
13190    #[test]
13191    fn windows_wire_spawn_prepares_pipe_and_keeps_phase_one_env() {
13192        let spec = spec(Vec::new());
13193        let mut command = Command::new("unused");
13194        let handoff = apply_wire_spawn_args(&mut command, &spec, None, None)
13195            .unwrap()
13196            .unwrap();
13197        let name = command
13198            .as_std()
13199            .get_envs()
13200            .find(|(key, _)| *key == OsStr::new(subc_os::launch_nonce::LAUNCH_NONCE_PIPE_ENV))
13201            .and_then(|(_, value)| value)
13202            .unwrap();
13203        assert_eq!(name, OsStr::new(handoff.name()));
13204        assert!(handoff.name().starts_with(r"\\.\pipe\subc-launch-"));
13205        assert!(command.as_std().get_envs().any(|(key, value)| key
13206            == OsStr::new(subc_os::LAUNCH_NONCE_PIPE_FALLBACK_ENV)
13207            && value == Some(OsStr::new("env"))));
13208        assert!(command
13209            .as_std()
13210            .get_envs()
13211            .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some()));
13212    }
13213
13214    #[cfg(windows)]
13215    #[test]
13216    fn windows_source_reporting_tracks_consumption_and_resets_per_spawn() {
13217        let mut snapshot = SupervisorSnapshot::new(ModuleState::Running, true);
13218        snapshot.process_alive = true;
13219        snapshot.spawned_protocol = Some(ModuleProtocol::Subc);
13220        assert_eq!(
13221            observed_launch_nonce_source(&snapshot).as_deref(),
13222            Some("env")
13223        );
13224        let consumed = Arc::new(OnceLock::new());
13225        snapshot.pipe_consumed = Some(consumed.clone());
13226        assert_eq!(
13227            observed_launch_nonce_source(&snapshot).as_deref(),
13228            Some("env")
13229        );
13230        consumed.set(()).unwrap();
13231        assert_eq!(
13232            observed_launch_nonce_source(&snapshot).as_deref(),
13233            Some("pipe")
13234        );
13235        snapshot.pipe_consumed = Some(Arc::new(OnceLock::new()));
13236        assert_eq!(
13237            observed_launch_nonce_source(&snapshot).as_deref(),
13238            Some("env")
13239        );
13240        clear_current_process_facts(&mut snapshot);
13241        assert_eq!(observed_launch_nonce_source(&snapshot), None);
13242    }
13243    use super::{
13244        apply_child_env, apply_spawn_role, apply_wire_spawn_args, ModuleProtocol, ModuleSpec,
13245        SpawnRole, SupervisorHandle, SPAWN_ROLE_SWAP_CANDIDATE, SUBC_ARG, SUBC_LAUNCH_NONCE_ENV,
13246        SUBC_MODULE_ID_ENV, SUBC_SPAWN_ROLE_ENV,
13247    };
13248    use std::{ffi::OsStr, path::PathBuf};
13249    use tokio::process::Command;
13250
13251    fn spec(env: Vec<(String, String)>) -> ModuleSpec {
13252        ModuleSpec {
13253            module_id: "env-plan".to_string(),
13254            program: PathBuf::from("/nonexistent"),
13255            args: Vec::new(),
13256            env,
13257            reserved: false,
13258            reserved_prefixes: Vec::new(),
13259            protocol: ModuleProtocol::Subc,
13260            overlap: Default::default(),
13261        }
13262    }
13263
13264    /// Ambient `CK_LOG` is REMOVED for an unconfigured module, and a configured
13265    /// one still gets its own.
13266    ///
13267    /// This is the narrow goal `env_clear()` was reached for, and the reason the
13268    /// fix is `env_remove` rather than deleting the line: an operator's ambient
13269    /// filter silently becoming an unconfigured module's log level is a real
13270    /// defect, just a much smaller one than clearing the environment.
13271    ///
13272    /// Asserted on the command plan rather than a spawned child because proving
13273    /// the ABSENCE of an inherited variable needs the parent's environment
13274    /// mutated, and `forbid(unsafe_code)` refuses that. `get_envs()` reports a
13275    /// removal as `(key, None)`, which is exactly the distinction wanted: not
13276    /// "absent because nobody set it" but "explicitly unset for the child".
13277    #[test]
13278    fn ambient_ck_log_is_removed_and_a_configured_one_survives() {
13279        let mut command = Command::new("/nonexistent");
13280        apply_child_env(&mut command, &spec(Vec::new()));
13281        let removed = command
13282            .as_std()
13283            .get_envs()
13284            .any(|(key, value)| key == OsStr::new("CK_LOG") && value.is_none());
13285        assert!(
13286            removed,
13287            "ambient CK_LOG must be explicitly removed for an unconfigured module"
13288        );
13289
13290        let mut configured = Command::new("/nonexistent");
13291        apply_child_env(
13292            &mut configured,
13293            &spec(vec![("CK_LOG".to_string(), "debug".to_string())]),
13294        );
13295        let effective = configured
13296            .as_std()
13297            .get_envs()
13298            .filter(|(key, _)| *key == OsStr::new("CK_LOG"))
13299            .last()
13300            .map(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()));
13301        assert_eq!(
13302            effective,
13303            Some(Some("debug".to_string())),
13304            "a module's configured CK_LOG must survive the ambient removal"
13305        );
13306    }
13307
13308    /// A `protocol: "none"` spawn carries NO `--subc` argument and NO launch
13309    /// nonce; a subc-wire spawn carries both. Asserted on the command plan for
13310    /// the same reason as the CK_LOG test above.
13311    ///
13312    /// The argument is the load-bearing half: a stock binary exits on an
13313    /// unknown flag before it listens, so with `--subc` appended the mode
13314    /// could not supervise the one process it exists for. Found by the first
13315    /// conformance run (nats-server: `flag provided but not defined: -subc`).
13316    #[test]
13317    fn protocol_none_spawn_carries_no_subc_argument_and_no_nonce() {
13318        let connection_file = std::path::Path::new("/run/subc-connection.json");
13319        let handle = SupervisorHandle::new();
13320
13321        let mut none_spec = spec(Vec::new());
13322        none_spec.protocol = ModuleProtocol::None;
13323        let mut none = Command::new("/nonexistent");
13324        let none_handoff =
13325            apply_wire_spawn_args(&mut none, &none_spec, Some(connection_file), Some(&handle))
13326                .expect("protocol-none spawn args apply");
13327        assert!(
13328            none_handoff.is_none(),
13329            "protocol:none spawn must not receive a nonce descriptor"
13330        );
13331        #[cfg(windows)]
13332        assert!(!none.as_std().get_envs().any(|(key, value)| key
13333            == OsStr::new(subc_os::LAUNCH_NONCE_PIPE_FALLBACK_ENV)
13334            && value.is_some()));
13335        assert!(
13336            !none.as_std().get_envs().any(|(key, value)| key
13337                == OsStr::new(subc_os::LAUNCH_NONCE_FD_ENV)
13338                && value.is_some()),
13339            "protocol:none spawn must not name a nonce descriptor"
13340        );
13341        let none_args: Vec<String> = none
13342            .as_std()
13343            .get_args()
13344            .map(|a| a.to_string_lossy().into_owned())
13345            .collect();
13346        assert!(
13347            !none_args.iter().any(|a| a == SUBC_ARG),
13348            "protocol:none argv must not carry --subc; got {none_args:?}"
13349        );
13350        let none_has_nonce = none
13351            .as_std()
13352            .get_envs()
13353            .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some());
13354        assert!(
13355            !none_has_nonce,
13356            "protocol:none spawn must not receive a launch nonce"
13357        );
13358        let none_has_module_id = none
13359            .as_std()
13360            .get_envs()
13361            .any(|(key, value)| key == OsStr::new(SUBC_MODULE_ID_ENV) && value.is_some());
13362        assert!(
13363            none_has_module_id,
13364            "SUBC_MODULE_ID is inert and stays on every path"
13365        );
13366        assert!(
13367            handle.spawn_nonce(&none_spec.module_id).is_none(),
13368            "no nonce record for a process that will never present one"
13369        );
13370
13371        // Control: the subc-wire path is unchanged by the branch above.
13372        let wire_spec = spec(Vec::new());
13373        let mut wire = Command::new("/nonexistent");
13374        let wire_handoff =
13375            apply_wire_spawn_args(&mut wire, &wire_spec, Some(connection_file), Some(&handle))
13376                .expect("subc-wire spawn args apply");
13377        let wire_fd_env = wire
13378            .as_std()
13379            .get_envs()
13380            .find(|(key, _)| *key == OsStr::new(subc_os::LAUNCH_NONCE_FD_ENV))
13381            .and_then(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()));
13382        #[cfg(unix)]
13383        assert_eq!(
13384            wire_fd_env,
13385            Some(wire_handoff.expect("a descriptor handoff").fd_env_value()),
13386            "a subc-wire spawn names the pipe it will receive at descriptor 3"
13387        );
13388        #[cfg(windows)]
13389        assert!(wire_handoff.is_some() && wire_fd_env.is_none());
13390        #[cfg(not(any(unix, windows)))]
13391        assert!(wire_handoff.is_none() && wire_fd_env.is_none());
13392        let wire_args: Vec<String> = wire
13393            .as_std()
13394            .get_args()
13395            .map(|a| a.to_string_lossy().into_owned())
13396            .collect();
13397        assert_eq!(
13398            wire_args,
13399            vec![
13400                SUBC_ARG.to_string(),
13401                connection_file.to_string_lossy().into_owned()
13402            ],
13403            "a subc-wire spawn still carries --subc <path>"
13404        );
13405        assert_eq!(
13406            wire.as_std()
13407                .get_envs()
13408                .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some()),
13409            !cfg!(unix),
13410            "only Windows supplies the environment nonce"
13411        );
13412        assert!(handle.spawn_nonce(&wire_spec.module_id).is_some());
13413    }
13414
13415    /// A plain spawn EXPLICITLY REMOVES the spawn role, even when the module's
13416    /// spec tries to set it; only a swap candidate carries it.
13417    ///
13418    /// "Set it only on candidates" is not enough, because spawn applies the
13419    /// spec's env verbatim and the daemon's own environment is inherited: either
13420    /// could hand a plain restart the swap role, and a module reading it would
13421    /// warm on its long swap budget while callers wait. Asserted as an explicit
13422    /// removal (`(key, None)`), not mere absence, for the reason the `CK_LOG`
13423    /// test above gives.
13424    #[test]
13425    fn plain_spawn_removes_the_spawn_role_even_when_the_spec_sets_it() {
13426        let role = |command: &Command| {
13427            command
13428                .as_std()
13429                .get_envs()
13430                .filter(|(key, _)| *key == OsStr::new(SUBC_SPAWN_ROLE_ENV))
13431                .last()
13432                .map(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()))
13433        };
13434        let forged = spec(vec![(
13435            SUBC_SPAWN_ROLE_ENV.to_string(),
13436            SPAWN_ROLE_SWAP_CANDIDATE.to_string(),
13437        )]);
13438
13439        let mut plain = Command::new("/nonexistent");
13440        apply_child_env(&mut plain, &forged);
13441        apply_spawn_role(&mut plain, SpawnRole::Plain);
13442        assert_eq!(
13443            role(&plain),
13444            Some(None),
13445            "a plain spawn must remove SUBC_SPAWN_ROLE, whatever the spec says"
13446        );
13447
13448        let mut candidate = Command::new("/nonexistent");
13449        apply_child_env(&mut candidate, &spec(Vec::new()));
13450        apply_spawn_role(&mut candidate, SpawnRole::SwapCandidate);
13451        assert_eq!(
13452            role(&candidate),
13453            Some(Some(SPAWN_ROLE_SWAP_CANDIDATE.to_string()))
13454        );
13455    }
13456
13457    /// Daemon-private capture retention keys never reach the child.
13458    ///
13459    /// cortexkit-log exposes retention as a Rust struct with no environment
13460    /// names, so these entries are supervisor metadata. Passing them through
13461    /// would invent a public child-process contract by accident.
13462    #[test]
13463    fn daemon_private_capture_keys_are_not_passed_to_the_child() {
13464        let mut command = Command::new("/nonexistent");
13465        apply_child_env(
13466            &mut command,
13467            &spec(vec![
13468                (super::CAPTURE_KEEP_ENV.to_string(), "5".to_string()),
13469                ("KEPT".to_string(), "yes".to_string()),
13470            ]),
13471        );
13472        let keys: Vec<String> = command
13473            .as_std()
13474            .get_envs()
13475            .filter(|(_, value)| value.is_some())
13476            .map(|(key, _)| key.to_string_lossy().into_owned())
13477            .collect();
13478        assert!(keys.contains(&"KEPT".to_string()), "got {keys:?}");
13479        assert!(
13480            !keys.contains(&super::CAPTURE_KEEP_ENV.to_string()),
13481            "daemon-private capture key leaked to the child: {keys:?}"
13482        );
13483    }
13484}
13485
13486#[cfg(test)]
13487mod jitter_tests {
13488    use super::jittered_health_delay;
13489    use std::{collections::HashSet, time::Duration};
13490
13491    /// Module ids drawn from a real fleet, so the dispersal claim is about names
13492    /// that actually occur rather than invented ones.
13493    ///
13494    /// This is a SAMPLE, not a registry: the property under test is that distinct
13495    /// ids disperse, which holds for any set of distinct strings. Several entries
13496    /// are already historical (modules get renamed), and that costs nothing here --
13497    /// but it means a reader must not mistake this for the live module set, and a
13498    /// rename sweep will match it without there being anything to change.
13499    const FLEET: [&str; 14] = [
13500        "aft",
13501        "alfonso-core",
13502        "magic-context",
13503        "broca",
13504        "thalamus",
13505        "quota",
13506        "engram",
13507        "plexus",
13508        "cerebellum",
13509        "astrocyte",
13510        "synapse",
13511        "subc-mcp",
13512        "cortexkit-credentials",
13513        "subc-federation",
13514    ];
13515
13516    /// Probes must not converge after a fleet-wide restart.
13517    ///
13518    /// This is the property the jitter exists for: every module reconnects at
13519    /// once, and without dispersal all fourteen would then probe on the same
13520    /// tick forever. Nothing failed visibly when this went untested -- a
13521    /// convergent fleet still probes correctly, just in a burst, so the symptom
13522    /// is a periodic load spike that looks like whatever else is running.
13523    #[test]
13524    fn probe_delays_disperse_across_the_fleet() {
13525        let cadence = Duration::from_secs(30);
13526        let delays: HashSet<Duration> = FLEET
13527            .iter()
13528            .map(|id| jittered_health_delay(id, 0, cadence))
13529            .collect();
13530        assert_eq!(
13531            delays.len(),
13532            FLEET.len(),
13533            "every supervised module must land on its own probe offset"
13534        );
13535    }
13536
13537    /// The offset may only ever DELAY a probe, never bring it forward.
13538    ///
13539    /// A delay below the cadence would probe a module more often than
13540    /// configured, which is the opposite of what an operator asked for and
13541    /// would tighten the failure budget without anyone changing it.
13542    #[test]
13543    fn jitter_only_delays_and_stays_within_one_tenth_of_cadence() {
13544        let cadence = Duration::from_secs(30);
13545        let span = cadence / 10;
13546        for id in FLEET {
13547            for probe_index in 0..8 {
13548                let delay = jittered_health_delay(id, probe_index, cadence);
13549                assert!(
13550                    delay >= cadence,
13551                    "{id}#{probe_index}: jitter must not shorten the cadence"
13552                );
13553                assert!(
13554                    delay < cadence + span,
13555                    "{id}#{probe_index}: jitter must stay inside one tenth of the cadence"
13556                );
13557            }
13558        }
13559    }
13560
13561    /// A module keeps its offset across daemon restarts.
13562    ///
13563    /// The delay is derived rather than randomised precisely so a restart does
13564    /// not re-roll every module into a fresh chance of collision. A random
13565    /// source would satisfy the dispersal test above and quietly lose this.
13566    #[test]
13567    fn a_module_offset_is_stable_across_restarts() {
13568        let cadence = Duration::from_secs(30);
13569        for id in FLEET {
13570            assert_eq!(
13571                jittered_health_delay(id, 0, cadence),
13572                jittered_health_delay(id, 0, cadence),
13573                "{id}: the same module and probe index must produce the same offset"
13574            );
13575        }
13576    }
13577
13578    /// A zero cadence disables probing rather than producing a busy loop.
13579    #[test]
13580    fn zero_cadence_yields_zero_delay() {
13581        assert_eq!(
13582            jittered_health_delay("aft", 0, Duration::ZERO),
13583            Duration::ZERO
13584        );
13585    }
13586}
13587
13588#[cfg(all(test, target_os = "linux"))]
13589mod cgroup_placement_tests {
13590    use super::{
13591        apply_cgroup_placement, remove_module_cgroup, ModuleProtocol, ModuleSpec, SuperviseError,
13592        SupervisedChild,
13593    };
13594    use crate::stderr_tail::{StderrRing, StderrTailConfig};
13595    use cortexkit_test_support::ScratchDir;
13596    use std::{
13597        fs, io,
13598        path::{Path, PathBuf},
13599        sync::{Arc, Mutex},
13600    };
13601    use tokio::process::Command;
13602
13603    #[tokio::test]
13604    async fn unique_spawn_cgroups_clean_up_across_restarts_and_shutdown() {
13605        use super::*;
13606        let dir = ScratchDir::new("unique-spawn-cgroups");
13607        let root = PathBuf::from(format!(
13608            "/sys/fs/cgroup/subc-unique-test-{}-{}",
13609            std::process::id(),
13610            unix_ms_now()
13611        ));
13612        if let Err(error) = fs::create_dir(&root) {
13613            assert!(
13614                std::env::var_os("SUBC_REQUIRE_CGROUP_TEST").is_none(),
13615                "required cgroup test cannot execute: {error}"
13616            );
13617            eprintln!("SKIP unique_spawn_cgroups_clean_up_across_restarts_and_shutdown: {error}");
13618            return;
13619        }
13620        let placement = subc_cgroup::prepare_at(&root).unwrap().unwrap();
13621        let group_count = || {
13622            fs::read_dir(root.join("subc-modules"))
13623                .unwrap()
13624                .map(|entry| entry.unwrap().file_type().unwrap())
13625                .filter(|kind| kind.is_dir())
13626                .count()
13627        };
13628        let supervisor =
13629            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
13630                .with_cgroup_placement(Some(placement.clone()));
13631        let runtime = supervisor.runtime_config();
13632        let mut spec = ModuleSpec {
13633            module_id: "unique-spawn".into(),
13634            program: PathBuf::from("/bin/sleep"),
13635            args: vec!["60".into()],
13636            env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
13637                .into_iter()
13638                .map(|key| (key.into(), dir.to_string_lossy().into_owned()))
13639                .collect(),
13640            reserved: false,
13641            reserved_prefixes: vec![],
13642            protocol: ModuleProtocol::None,
13643            overlap: Default::default(),
13644        };
13645        let spawn = |spec: &ModuleSpec| {
13646            spawn_child(
13647                spec,
13648                None,
13649                None,
13650                &runtime.stderr_ring,
13651                None,
13652                &runtime.child_roster,
13653                Some(&placement),
13654            )
13655            .unwrap()
13656        };
13657        let mut live = spawn(&spec);
13658        for _ in 0..3 {
13659            // A new process can enter the old slot while retirement is pending.
13660            let next = spawn(&spec);
13661            assert_ne!(live.module_id, next.module_id);
13662            live.start_kill().unwrap();
13663            live.wait().await.unwrap();
13664            live = next;
13665            assert!(
13666                live.child.try_wait().unwrap().is_none(),
13667                "retiring the old slot must not kill the replacement"
13668            );
13669            assert_eq!(
13670                group_count(),
13671                1,
13672                "only the live spawn's cgroup should remain"
13673            );
13674        }
13675        supervisor.begin_daemon_shutdown();
13676        let reap = tokio::spawn(async move {
13677            live.wait().await.unwrap();
13678        });
13679        supervisor
13680            .end_children_for_daemon_shutdown(false, std::future::pending())
13681            .await;
13682        reap.await.unwrap();
13683        assert_eq!(group_count(), 0);
13684        // A normal exit uses the same tree-cleanup path as a killed spawn.
13685        spec.program = PathBuf::from("/bin/true");
13686        spec.args.clear();
13687        let fresh_roster = ChildRoster::default();
13688        let mut short = spawn_child(
13689            &spec,
13690            None,
13691            None,
13692            &runtime.stderr_ring,
13693            None,
13694            &fresh_roster,
13695            Some(&placement),
13696        )
13697        .unwrap();
13698        short.wait().await.unwrap();
13699        assert_eq!(group_count(), 0);
13700        spec.module_id = "_".repeat(255);
13701        let mut long_id = spawn_child(
13702            &spec,
13703            None,
13704            None,
13705            &runtime.stderr_ring,
13706            None,
13707            &fresh_roster,
13708            Some(&placement),
13709        )
13710        .unwrap();
13711        long_id.wait().await.unwrap();
13712        assert_eq!(
13713            group_count(),
13714            0,
13715            "valid long module IDs must not exceed cgroup NAME_MAX"
13716        );
13717        fs::remove_dir(root.join("subc-modules")).unwrap();
13718        fs::remove_dir(root).unwrap();
13719        eprintln!("EXECUTED unique_spawn_cgroups_clean_up_across_restarts_and_shutdown");
13720    }
13721
13722    #[test]
13723    fn failed_parent_cgroup_open_is_a_cgroup_supervision_error() {
13724        let path = Path::new("/definitely-missing-subc-cgroup");
13725        let mut command = Command::new("true");
13726        let error = apply_cgroup_placement(
13727            &mut command,
13728            &ModuleSpec {
13729                module_id: "broken-cgroup".to_string(),
13730                program: PathBuf::from("true"),
13731                args: Vec::new(),
13732                env: Vec::new(),
13733                reserved: false,
13734                reserved_prefixes: Vec::new(),
13735                protocol: ModuleProtocol::Subc,
13736                overlap: Default::default(),
13737            },
13738            path,
13739        )
13740        .expect_err("a parent cgroup open failure must reject the supervised spawn");
13741        let reason = error.to_string();
13742
13743        assert!(
13744            matches!(error, SuperviseError::Cgroup { .. }),
13745            "parent cgroup open must be reported as a cgroup supervision error: {reason}"
13746        );
13747        assert!(
13748            reason.contains("/definitely-missing-subc-cgroup/cgroup.procs"),
13749            "parent cgroup open failure must name cgroup.procs: {reason}"
13750        );
13751    }
13752
13753    #[tokio::test]
13754    async fn reaping_a_child_removes_its_empty_module_cgroup() {
13755        let root = ScratchDir::new("supervisor-reap-cgroup");
13756        fs::write(root.join("cgroup.procs"), b"").expect("write scratch cgroup marker");
13757        let placement = subc_cgroup::prepare_at(&root)
13758            .expect("prepare scratch cgroup root")
13759            .expect("scratch root has a cgroup.procs marker");
13760        let module_id = "reaped-module";
13761        let module = placement
13762            .module_path(module_id)
13763            .expect("create scratch module cgroup");
13764        let child = Command::new("true")
13765            .env("XDG_DATA_HOME", root.path())
13766            .env("XDG_RUNTIME_DIR", root.path())
13767            .env("XDG_CONFIG_HOME", root.path())
13768            .spawn()
13769            .expect("spawn short-lived child");
13770        let pid = child.id().expect("spawned child has pid");
13771        let mut child = SupervisedChild {
13772            child,
13773            protocol: ModuleProtocol::Subc,
13774            module_id: module_id.to_string(),
13775            cgroup_placement: Some(placement),
13776            stdout_pump: None,
13777            stderr_pump: None,
13778            stderr_ring: Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default()))),
13779            spawned_at_ms: 0,
13780            spawned_from: PathBuf::from("true"),
13781            spawned_file_identity: None,
13782            process_start_time: None,
13783            process_identity: None,
13784            pid,
13785            roster_guard: None,
13786            #[cfg(target_os = "macos")]
13787            privacy_exec: None,
13788            spawn_failure: None,
13789        };
13790
13791        child.wait().await.expect("reap short-lived child");
13792
13793        assert!(
13794            !module.exists(),
13795            "reaping the supervised child must remove its empty cgroup"
13796        );
13797    }
13798
13799    #[test]
13800    fn non_empty_cgroup_removal_is_reported_without_blocking_teardown() {
13801        let root = ScratchDir::new("supervisor-non-empty-cgroup");
13802        fs::write(root.join("cgroup.procs"), b"").expect("write scratch cgroup marker");
13803        let placement = subc_cgroup::prepare_at(&root)
13804            .expect("prepare scratch cgroup root")
13805            .expect("scratch root has a cgroup.procs marker");
13806        let module = placement
13807            .module_path("surviving-module")
13808            .expect("create scratch module cgroup");
13809        fs::write(module.join("surviving-process"), b"still present")
13810            .expect("make scratch cgroup non-empty");
13811        let (logs, _guard) = crate::router::test_log::log_capture(tracing::Level::WARN);
13812
13813        remove_module_cgroup(&placement, "surviving-module");
13814
13815        let logs = crate::router::test_log::captured_logs(&logs);
13816        assert!(
13817            module.exists(),
13818            "failed removal must leave the cgroup intact"
13819        );
13820        assert!(
13821            logs.contains("could not remove module cgroup after process exit; continuing teardown")
13822                && logs.contains("surviving-module"),
13823            "best-effort removal must report the failure without returning it: {logs}"
13824        );
13825    }
13826
13827    #[test]
13828    fn cgroup_pre_exec_spawn_failure_names_the_cgroup_path() {
13829        let cgroup_path = PathBuf::from("/sys/fs/cgroup/subc-modules/broken-module");
13830        let reason = SuperviseError::Spawn {
13831            program: PathBuf::from("/bin/true"),
13832            source: io::Error::from_raw_os_error(13),
13833            cgroup_path: Some(cgroup_path.clone()),
13834        }
13835        .to_string();
13836
13837        assert!(
13838            reason.contains(&cgroup_path.display().to_string()),
13839            "a pre_exec spawn failure must name the cgroup path: {reason}"
13840        );
13841    }
13842}
13843
13844#[cfg(test)]
13845mod spawn_subscriber_lag_tests {
13846    use super::*;
13847
13848    /// A subscriber whose connection stops draining is dropped once its frame
13849    /// channel fills. The client must learn that from a terminal Error frame
13850    /// after the frames already queued for it, not from a stream that simply
13851    /// goes quiet.
13852    #[tokio::test]
13853    async fn lagged_spawn_subscriber_receives_a_terminal_lagged_error_after_its_queued_frames() {
13854        let feed = SpawnEventFeed::default();
13855        feed.configure_incarnation("lag-incarnation".to_string());
13856        // A one-slot connection queue that nobody reads until the emits are
13857        // done: the forwarder parks on it and the subscriber channel fills.
13858        let (tx, mut rx) = mpsc::channel(1);
13859        feed.subscribe(ConnectionId::new(1), 7, 1, None, FrameSink::new(tx))
13860            .expect("subscribe");
13861        let emitted = SPAWN_SUBSCRIBER_BUFFER + 16;
13862        for index in 0..emitted {
13863            feed.emit_spawned(&format!("lag-module-{index}"), 1000, 0);
13864            // Let the forwarder take what it can so the fill point is the
13865            // subscriber channel, not a scheduling accident.
13866            tokio::task::yield_now().await;
13867        }
13868        assert_eq!(
13869            feed.subscriber_count(),
13870            0,
13871            "the lagged subscriber must be removed"
13872        );
13873
13874        let mut data = Vec::new();
13875        let mut last = None;
13876        loop {
13877            let next = tokio::time::timeout(Duration::from_secs(5), rx.recv())
13878                .await
13879                .expect("the forwarder must finish once the subscriber is dropped");
13880            let Some(outbound) = next else { break };
13881            let frame = outbound.frame;
13882            if frame.header.ty == FrameType::StreamData {
13883                assert!(last.is_none(), "no data may follow the terminal frame");
13884                let event: SpawnEvent = serde_json::from_slice(&frame.body).unwrap();
13885                data.push(event.cursor.seq);
13886            } else {
13887                assert!(last.is_none(), "exactly one terminal frame");
13888                last = Some(frame);
13889            }
13890        }
13891        assert!(!data.is_empty(), "queued frames drain before the terminal");
13892        for pair in data.windows(2) {
13893            assert_eq!(
13894                pair[1],
13895                pair[0] + 1,
13896                "queued frames arrive dense and in order"
13897            );
13898        }
13899        let terminal = last.expect("a lagged subscriber must receive a terminal frame");
13900        assert_eq!(terminal.header.ty, FrameType::Error);
13901        assert_eq!(terminal.header.corr, 7);
13902        let body: subc_protocol::ErrorBody = serde_json::from_slice(&terminal.body).unwrap();
13903        assert_eq!(body.code, SPAWN_SUBSCRIBER_LAGGED_CODE);
13904        let detail = body.detail.expect("lagged error carries detail");
13905        assert_eq!(
13906            detail["first_undelivered_cursor"]["seq"],
13907            data.last().unwrap() + 1,
13908            "the named cursor is the first event the subscriber did not receive"
13909        );
13910        assert_eq!(
13911            detail["first_undelivered_cursor"]["daemon_incarnation"],
13912            "lag-incarnation"
13913        );
13914    }
13915}
13916
13917#[cfg(test)]
13918mod terminal_history_read_concurrency_tests {
13919    use super::*;
13920    use crate::terminal_journal::read_pause;
13921    use cortexkit_test_support::ScratchDir;
13922    use std::sync::mpsc as std_mpsc;
13923
13924    fn journaled_ring(
13925        journal: &Arc<crate::terminal_journal::TerminalJournal>,
13926    ) -> Arc<Mutex<TerminalRing>> {
13927        Arc::new(Mutex::new(
13928            TerminalRing::new(TerminalRingConfig::default(), 1)
13929                .with_journal(Some(Arc::clone(journal))),
13930        ))
13931    }
13932
13933    fn crash(at_ms: u64) -> ExitReport {
13934        ExitReport {
13935            kind: ExitKind::Crash,
13936            code: Some(1),
13937            signal: None,
13938            at_ms,
13939        }
13940    }
13941
13942    /// Record an exit on another thread and report whether it finished within
13943    /// `bound`. The recorder thread is left running if it did not.
13944    fn record_within(
13945        module_id: &'static str,
13946        ring: &Arc<Mutex<TerminalRing>>,
13947        at_ms: u64,
13948        bound: Duration,
13949    ) -> bool {
13950        let ring = Arc::clone(ring);
13951        let (done, done_rx) = std_mpsc::channel();
13952        std::thread::spawn(move || {
13953            record_terminal(
13954                module_id,
13955                &ring,
13956                &SpawnEventFeed::default(),
13957                &crash(at_ms),
13958                TerminalDisposition::Restarting,
13959            );
13960            let _ = done.send(());
13961        });
13962        done_rx.recv_timeout(bound).is_ok()
13963    }
13964
13965    /// A history read in progress must not hold the journal writer (which every
13966    /// module's exit recording needs) or the module's own ring. Exits recorded
13967    /// while the read is paused complete promptly; the paused read answers as of
13968    /// the moment it started, and the next read has each exit exactly once.
13969    #[test]
13970    fn exits_recorded_during_a_paused_history_read_are_not_blocked_or_half_merged() {
13971        let dir = ScratchDir::new("terminal-history-concurrent-read");
13972        let path = dir.join("terminals.jsonl");
13973        let journal = Arc::new(crate::terminal_journal::TerminalJournal::open(
13974            path.clone(),
13975            "daemon".into(),
13976        ));
13977        let reader_ring = journaled_ring(&journal);
13978        let other_ring = journaled_ring(&journal);
13979        assert!(record_within(
13980            "reader-module",
13981            &reader_ring,
13982            10,
13983            Duration::from_secs(5)
13984        ));
13985
13986        let (started, release) = read_pause::install(&path);
13987        let reading = {
13988            let ring = Arc::clone(&reader_ring);
13989            std::thread::spawn(move || durable_terminal_history_of(&ring, "reader-module"))
13990        };
13991        started
13992            .recv_timeout(Duration::from_secs(5))
13993            .expect("the history read reached its pause");
13994
13995        let bound = Duration::from_secs(1);
13996        assert!(
13997            record_within("other-module", &other_ring, 20, bound),
13998            "another module's exit waited on a history read (journal writer held)"
13999        );
14000        assert!(
14001            record_within("reader-module", &reader_ring, 30, bound),
14002            "the read module's own exit waited on its history read (ring held)"
14003        );
14004
14005        drop(release);
14006        let paused = reading.join().unwrap();
14007        assert_eq!(
14008            paused.entries.iter().map(|e| e.at_ms).collect::<Vec<_>>(),
14009            vec![10],
14010            "an exit recorded after the read began lands in neither half of it"
14011        );
14012        assert_eq!(paused.journal_skipped_lines, 0);
14013        assert_eq!(paused.journal_read_errors, 0);
14014
14015        let after = durable_terminal_history_of(&reader_ring, "reader-module");
14016        assert_eq!(
14017            after.entries.iter().map(|e| e.at_ms).collect::<Vec<_>>(),
14018            vec![10, 30],
14019            "the next read merges ring and journal with no duplicate"
14020        );
14021        assert_eq!(after.journal_skipped_lines, 0);
14022    }
14023}
14024
14025/// What a restart does with the exited process's output readers. These drive
14026/// the same settle functions the supervisor calls, with a reader the test
14027/// holds, so a reader that has not been scheduled by the bound is a controlled
14028/// input rather than something only a loaded machine produces.
14029#[cfg(test)]
14030mod stderr_settle_tests {
14031    use std::{
14032        future::Future,
14033        io,
14034        pin::Pin,
14035        sync::{Arc, Mutex},
14036        task::{Context, Poll},
14037        time::Duration,
14038    };
14039
14040    use tokio::{
14041        io::{AsyncRead, ReadBuf},
14042        sync::oneshot,
14043        time::Instant,
14044    };
14045
14046    use super::{settle_stderr_pump, settle_stdout_pump, StderrPump};
14047    use crate::stderr_tail::{
14048        pump_stderr_to, pump_stdout_to, split_capture_stamp, untimed, CaptureState,
14049        ChildOutputSink, OutputSink, StderrRing, StderrTailConfig, TailEntry,
14050    };
14051
14052    const BOUND: Duration = Duration::from_millis(250);
14053
14054    /// Yields `before`, then stays pending until the gate is released, then
14055    /// yields `after` and reaches EOF. The bytes after the gate were written
14056    /// by a process that has already exited; only the reader is behind.
14057    struct HeldReader {
14058        before: Option<Vec<u8>>,
14059        gate: Option<oneshot::Receiver<()>>,
14060        after: io::Cursor<Vec<u8>>,
14061    }
14062
14063    impl AsyncRead for HeldReader {
14064        fn poll_read(
14065            mut self: Pin<&mut Self>,
14066            cx: &mut Context<'_>,
14067            buf: &mut ReadBuf<'_>,
14068        ) -> Poll<io::Result<()>> {
14069            if let Some(bytes) = self.before.take() {
14070                buf.put_slice(&bytes);
14071                return Poll::Ready(Ok(()));
14072            }
14073            if let Some(gate) = self.gate.as_mut() {
14074                match Pin::new(gate).poll(cx) {
14075                    Poll::Pending => return Poll::Pending,
14076                    Poll::Ready(_) => self.gate = None,
14077                }
14078            }
14079            Pin::new(&mut self.after).poll_read(cx, buf)
14080        }
14081    }
14082
14083    struct DiscardSink;
14084
14085    impl OutputSink for DiscardSink {
14086        fn write_line(&mut self, _line: &[u8]) {}
14087    }
14088
14089    fn line(text: &str) -> TailEntry {
14090        TailEntry::Line {
14091            text: text.to_string(),
14092            truncated: false,
14093            at_ms: None,
14094        }
14095    }
14096
14097    fn lock(ring: &Arc<Mutex<StderrRing>>) -> std::sync::MutexGuard<'_, StderrRing> {
14098        ring.lock().unwrap()
14099    }
14100
14101    /// Start a reader for a new process generation that delivers `before`
14102    /// immediately and `after` only once the returned sender fires (or is
14103    /// dropped).
14104    fn held_pump(
14105        ring: &Arc<Mutex<StderrRing>>,
14106        before: &str,
14107        after: &str,
14108    ) -> (StderrPump, oneshot::Sender<()>) {
14109        let generation = lock(ring).begin_process();
14110        let (release, gate) = oneshot::channel();
14111        let reader = HeldReader {
14112            before: Some(before.as_bytes().to_vec()),
14113            gate: Some(gate),
14114            after: io::Cursor::new(after.as_bytes().to_vec()),
14115        };
14116        let task = tokio::spawn(pump_stderr_to(
14117            reader,
14118            Arc::clone(ring),
14119            generation,
14120            DiscardSink,
14121        ));
14122        (StderrPump { task, generation }, release)
14123    }
14124
14125    async fn wait_until(ring: &Arc<Mutex<StderrRing>>, done: impl Fn(&StderrRing) -> bool) {
14126        for _ in 0..1000 {
14127            if done(&lock(ring)) {
14128                return;
14129            }
14130            tokio::time::sleep(Duration::from_millis(1)).await;
14131        }
14132        panic!(
14133            "ring never reached the expected state: {:?}",
14134            lock(ring).snapshot(None, None)
14135        );
14136    }
14137
14138    #[tokio::test(start_paused = true)]
14139    async fn a_crash_line_the_reader_had_not_reached_by_the_bound_is_kept_before_the_restart() {
14140        let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
14141        let (pump, release) = held_pump(&ring, "booting\n", "config error: missing storage\n");
14142
14143        settle_stderr_pump("crasher", &ring, pump, BOUND).await;
14144        let before_release = lock(&ring).snapshot(None, None);
14145        assert!(
14146            matches!(before_release.capture, CaptureState::Incomplete { .. }),
14147            "a reader that has not reached EOF cannot claim a whole tail: {before_release:?}"
14148        );
14149
14150        // The restart: the next process starts and writes before the old
14151        // reader catches up.
14152        let next = lock(&ring).begin_process();
14153        lock(&ring).push_line_from(next, "next process booting");
14154        release.send(()).unwrap();
14155        wait_until(&ring, |ring| {
14156            ring.snapshot(None, None).capture == CaptureState::Captured
14157        })
14158        .await;
14159
14160        assert_eq!(
14161            untimed(lock(&ring).snapshot(None, None).entries),
14162            vec![
14163                line("booting"),
14164                line("config error: missing storage"),
14165                TailEntry::ProcessStart,
14166                line("next process booting"),
14167            ],
14168            "the crash's last line must survive a slow reader and stay in the crashed process's section"
14169        );
14170    }
14171
14172    #[tokio::test(start_paused = true)]
14173    async fn a_pipe_held_open_by_a_descendant_reads_incomplete_without_delaying_the_restart_past_the_bound(
14174    ) {
14175        let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
14176        // `_held` is never fired: a descendant keeps the pipe open for the
14177        // whole test.
14178        let (pump, _held) = held_pump(&ring, "parent exiting\n", "");
14179
14180        let started = Instant::now();
14181        settle_stderr_pump("orphaning", &ring, pump, BOUND).await;
14182        assert_eq!(
14183            started.elapsed(),
14184            BOUND,
14185            "the restart must wait exactly the bound for a pipe that stays open, no longer"
14186        );
14187
14188        let next = lock(&ring).begin_process();
14189        lock(&ring).push_line_from(next, "next process booting");
14190        tokio::time::sleep(Duration::from_secs(60)).await;
14191
14192        let snapshot = lock(&ring).snapshot(None, None);
14193        match &snapshot.capture {
14194            CaptureState::Incomplete { reason } => assert!(
14195                reason.contains("had not reached EOF") && reason.contains("250ms"),
14196                "the reason must say what is missing and after how long: {reason}"
14197            ),
14198            other => panic!("expected Incomplete while the pipe is held open, got {other:?}"),
14199        }
14200        assert_eq!(
14201            untimed(snapshot.entries),
14202            vec![
14203                line("parent exiting"),
14204                TailEntry::ProcessStart,
14205                line("next process booting"),
14206            ]
14207        );
14208    }
14209
14210    #[tokio::test(start_paused = true)]
14211    async fn a_reader_that_reaches_eof_within_the_bound_leaves_the_tail_captured() {
14212        let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
14213        let (pump, release) = held_pump(&ring, "one\n", "two\n");
14214        release.send(()).unwrap();
14215
14216        settle_stderr_pump("clean", &ring, pump, BOUND).await;
14217
14218        let snapshot = lock(&ring).snapshot(None, None);
14219        assert_eq!(snapshot.capture, CaptureState::Captured);
14220        assert_eq!(untimed(snapshot.entries), vec![line("one"), line("two")]);
14221    }
14222
14223    #[tokio::test(start_paused = true)]
14224    async fn stdout_buffered_past_the_exit_drain_bound_reaches_the_capture_file() {
14225        const BURST: usize = 150;
14226        let capture = cortexkit_test_support::ScratchDir::new("stdout-exit-drain");
14227        let path = capture.join("two-pipe.stderr.log");
14228        let sink = ChildOutputSink::open(&path, cortexkit_log::Retention::default()).unwrap();
14229        let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
14230        let generation = lock(&ring).begin_process();
14231        let stdout = (0..BURST)
14232            .map(|i| format!("out-{i:04}-{}\n", "o".repeat(64)))
14233            .collect::<String>();
14234        let stderr = (0..BURST)
14235            .map(|i| format!("err-{i:04}-{}\n", "e".repeat(64)))
14236            .collect::<String>();
14237        // Model a child that has written both bursts and exited. Hold the stdout
14238        // reader after one line and a partial prefix, as if pipe readiness or
14239        // capture-file writes had delayed it. The final line also needs EOF to
14240        // flush: neither complete nor partial buffered output may be discarded.
14241        let first_chunk = stdout.find('\n').unwrap() + 1 + "out-0001-".len();
14242        let (release, gate) = oneshot::channel();
14243        let reader = HeldReader {
14244            before: Some(stdout.as_bytes()[..first_chunk].to_vec()),
14245            gate: Some(gate),
14246            after: io::Cursor::new(stdout.as_bytes()[first_chunk..stdout.len() - 1].to_vec()),
14247        };
14248        let (finished, completion) = oneshot::channel();
14249        let stdout_sink = sink.clone();
14250        let pump = tokio::spawn(async move {
14251            pump_stdout_to(reader, stdout_sink).await;
14252            finished.send(()).unwrap();
14253        });
14254        pump_stderr_to(io::Cursor::new(stderr.into_bytes()), ring, generation, sink).await;
14255
14256        let started = Instant::now();
14257        settle_stdout_pump("two-pipe", pump, BOUND).await;
14258        assert_eq!(
14259            started.elapsed(),
14260            BOUND,
14261            "a late reader must not hold up restart"
14262        );
14263        assert_eq!(
14264            std::fs::read_to_string(&path).unwrap().lines().count(),
14265            BURST + 1
14266        );
14267
14268        release
14269            .send(())
14270            .expect("stdout reader must survive the exit drain bound");
14271        tokio::time::timeout(Duration::from_secs(1), completion)
14272            .await
14273            .expect("released stdout reader must reach EOF")
14274            .expect("stdout capture must finish at EOF, not be cancelled");
14275        let contents = std::fs::read_to_string(&path).unwrap();
14276        let mut lines = contents
14277            .lines()
14278            .map(|line| split_capture_stamp(line).expect("capture stamp").1)
14279            .collect::<Vec<_>>();
14280        let mut expected = (0..BURST)
14281            .flat_map(|i| {
14282                [
14283                    format!("out-{i:04}-{}", "o".repeat(64)),
14284                    format!("err-{i:04}-{}", "e".repeat(64)),
14285                ]
14286            })
14287            .collect::<Vec<_>>();
14288        lines.sort_unstable();
14289        expected.sort_unstable();
14290        assert_eq!(
14291            lines, expected,
14292            "both pipes must retain every complete line exactly once"
14293        );
14294    }
14295}
14296
14297/// Containment of a module's process tree (issue #109).
14298///
14299/// The behaviour these defend against is a module helper surviving its module:
14300/// on a real machine the Synapse embedding module's CUDA worker holds ~2.2 GB of
14301/// VRAM, so a leaked grandchild is a leaked GPU allocation, and a day of restarts
14302/// compounds it.
14303///
14304/// They run against the SUPERVISOR rather than the job-object crate because the
14305/// claim is about teardown: a crate-level test proves a job can reap a tree, not
14306/// that the daemon's drain path reaches it.
14307///
14308/// Windows-only, like the mechanism. On Unix this arm compiles out; the cgroup
14309/// lane there is a separate containment path with its own tests.
14310#[cfg(all(test, windows))]
14311mod job_containment_tests {
14312    use super::*;
14313    use cortexkit_test_support::ScratchDir;
14314    use std::{
14315        path::{Path, PathBuf},
14316        sync::{Arc, Mutex},
14317        time::{Duration, Instant},
14318    };
14319
14320    /// The stub, expected beside this test executable.
14321    ///
14322    /// The existence check is here for the reason its twin at `fake_aft_stub_path`
14323    /// documents: `--lib` does not build `[[bin]]` targets, and a bare spawn
14324    /// failure then reads as a broken test rather than an unbuilt dependency.
14325    fn stub_path() -> PathBuf {
14326        let mut path = std::env::current_exe().expect("current_exe available in tests");
14327        path.pop();
14328        path.pop();
14329        path.push("fake-aft-stub.exe");
14330        assert!(
14331            path.exists(),
14332            "fake-aft-stub not built at {}: run `cargo test -p subc-core` (which builds \
14333             [[bin]] targets) rather than `cargo test -p subc-core --lib` (which does not)",
14334            path.display()
14335        );
14336        path
14337    }
14338
14339    /// Poll for the grandchild pid the stub records, and parse it.
14340    fn read_grandchild_pid(path: &Path) -> u32 {
14341        let deadline = Instant::now() + Duration::from_secs(10);
14342        loop {
14343            if let Ok(contents) = std::fs::read_to_string(path) {
14344                if let Ok(pid) = contents.trim().parse() {
14345                    return pid;
14346                }
14347            }
14348            assert!(
14349                Instant::now() < deadline,
14350                "the stub never recorded a grandchild pid at {}",
14351                path.display()
14352            );
14353            std::thread::sleep(Duration::from_millis(10));
14354        }
14355    }
14356
14357    /// Everything one fixture run needs, so the two tests below differ in exactly
14358    /// one place: whether the child is contained.
14359    struct Fixture {
14360        _dir: ScratchDir,
14361        module_id: String,
14362        grandchild: u32,
14363        child: Option<SupervisedChild>,
14364        registry: Arc<Registry>,
14365        snapshot: Arc<Mutex<SupervisorSnapshot>>,
14366        terminal_ring: Arc<Mutex<TerminalRing>>,
14367        spawn_events: SpawnEventFeed,
14368    }
14369
14370    fn fixture(label: &str, module_id: &str) -> Fixture {
14371        let dir = ScratchDir::new(label);
14372        let pid_file = dir.join("grandchild.pid");
14373        let supervisor = Supervisor::new_for_test(
14374            Arc::new(Registry::default()),
14375            RestartPolicy::new(3, Duration::ZERO),
14376        );
14377        let runtime = supervisor.runtime_config();
14378        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
14379        let spec = ModuleSpec {
14380            module_id: module_id.to_string(),
14381            program: stub_path(),
14382            // Zero args deliberately: a `--subc` argument would make the stub dial
14383            // a daemon that is not there, and the failure would land in the same
14384            // stderr ring this fixture exists to keep quiet.
14385            args: Vec::new(),
14386            env: vec![
14387                ("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string()),
14388                (
14389                    "FAKE_AFT_GRANDCHILD_PID_FILE".to_string(),
14390                    pid_file.display().to_string(),
14391                ),
14392            ],
14393            reserved: false,
14394            reserved_prefixes: Vec::new(),
14395            protocol: ModuleProtocol::Subc,
14396            overlap: Default::default(),
14397        };
14398        let child = spawn_and_mark_running(&spec, &runtime, &snapshot)
14399            .expect("spawn the supervised fixture");
14400        let grandchild = read_grandchild_pid(&pid_file);
14401        Fixture {
14402            _dir: dir,
14403            module_id: module_id.to_string(),
14404            grandchild,
14405            child: Some(child),
14406            registry: Arc::new(Registry::default()),
14407            snapshot,
14408            terminal_ring: Arc::clone(&runtime.terminal_ring),
14409            spawn_events: SpawnEventFeed::default(),
14410        }
14411    }
14412
14413    impl Fixture {
14414        /// Drain through the supervisor's own teardown path.
14415        async fn drain(&mut self) {
14416            let child = self
14417                .child
14418                .take()
14419                .expect("the fixture child is still present");
14420            drain_child_to_state(
14421                &self.module_id,
14422                ModuleProtocol::Subc,
14423                // No forwarding table in this fixture, so nothing reaches the
14424                // child over a connection.
14425                StopNotice::NotSent,
14426                &self.registry,
14427                None,
14428                &self.snapshot,
14429                &self.terminal_ring,
14430                &self.spawn_events,
14431                child,
14432                Duration::from_millis(500),
14433                ModuleState::Stopped,
14434                Some(false),
14435            )
14436            .await
14437            .expect("drain the supervised fixture");
14438        }
14439    }
14440
14441    /// Teardown reaps the grandchild, not merely the direct child.
14442    ///
14443    /// This is the assertion the change exists for. Before containment the
14444    /// grandchild survived: it is a separate process, and `start_kill` is
14445    /// `TerminateProcess` scoped to one pid.
14446    #[tokio::test]
14447    async fn teardown_reaps_the_grandchild() {
14448        let mut fixture = fixture("teardown-grandchild", "tree-teardown");
14449        let grandchild = fixture.grandchild;
14450
14451        assert!(
14452            subc_jobobject::process_exists(grandchild),
14453            "grandchild {grandchild} must be alive before teardown, or this proves nothing"
14454        );
14455
14456        fixture.drain().await;
14457
14458        assert!(
14459            subc_jobobject::wait_for_process_exit(grandchild, Duration::from_secs(10)),
14460            "grandchild {grandchild} outlived module teardown: the tree was not contained"
14461        );
14462    }
14463
14464    /// The mutation control: with containment withheld, the grandchild survives
14465    /// the same kill.
14466    ///
14467    /// This is the defect reproduction from #109 — a direct-child kill reaches
14468    /// one pid, and the grandchild is a different process. It spawns OUTSIDE the
14469    /// supervisor because `spawn_and_mark_running` now always contains on
14470    /// Windows, which is the point: there is no longer a path that spawns
14471    /// uncontained, so the control has to construct one.
14472    ///
14473    /// Its job is to keep `teardown_reaps_the_grandchild` honest. If the
14474    /// grandchild ever dies here, that test is passing for a reason unrelated to
14475    /// the job object and the containment claim is unproven.
14476    #[test]
14477    fn an_uncontained_grandchild_survives_a_direct_child_kill() {
14478        let dir = ScratchDir::new("teardown-uncontained");
14479        let pid_file = dir.join("grandchild.pid");
14480        let mut child = std::process::Command::new(stub_path())
14481            .env("FAKE_AFT_NEVER_CONNECT", "1")
14482            .env(
14483                "FAKE_AFT_GRANDCHILD_PID_FILE",
14484                pid_file.display().to_string(),
14485            )
14486            .stdin(std::process::Stdio::null())
14487            .stdout(std::process::Stdio::null())
14488            .stderr(std::process::Stdio::null())
14489            .spawn()
14490            .expect("spawn the uncontained fixture");
14491        let grandchild = read_grandchild_pid(&pid_file);
14492
14493        // Exactly what the pre-fix teardown did: kill the direct child.
14494        child.kill().expect("kill the direct child");
14495        let _ = child.wait();
14496
14497        assert!(
14498            subc_jobobject::process_exists(grandchild),
14499            "grandchild {grandchild} died with the direct child, so this control no longer \
14500             distinguishes contained from uncontained teardown and the regression test is \
14501             passing vacuously"
14502        );
14503
14504        // The orphan this control demonstrates is the leak the fix prevents, so
14505        // the control must not leave one behind.
14506        kill_tree(grandchild);
14507    }
14508
14509    /// Crash durability: closing the containment handle reaps the tree with no
14510    /// teardown code running at all.
14511    ///
14512    /// This is the case `taskkill /T` cannot cover — a daemon that dies cannot
14513    /// call anything — and it is why containment is a kernel property of the
14514    /// handle rather than a step in the drain. Discovered by getting the
14515    /// mutation control wrong: clearing `job` to "disable" containment instead
14516    /// killed the tree, which is the guarantee, not a mistake.
14517    #[tokio::test]
14518    async fn dropping_containment_reaps_the_grandchild() {
14519        let mut fixture = fixture("drop-containment", "tree-drop");
14520        let grandchild = fixture.grandchild;
14521
14522        assert!(subc_jobobject::process_exists(grandchild));
14523
14524        // No `drain` call, no kill: dropping the handle is the entire mechanism.
14525        fixture.child.as_mut().expect("child present").job = None;
14526
14527        assert!(
14528            subc_jobobject::wait_for_process_exit(grandchild, Duration::from_secs(10)),
14529            "grandchild {grandchild} survived the containment handle closing, so a daemon \
14530             crash would leave the tree behind"
14531        );
14532    }
14533
14534    /// Kill a pid and its tree, then confirm it is gone.
14535    fn kill_tree(pid: u32) {
14536        let _ = std::process::Command::new("taskkill.exe")
14537            .args(["/PID", &pid.to_string(), "/T", "/F"])
14538            .stdin(std::process::Stdio::null())
14539            .stdout(std::process::Stdio::null())
14540            .stderr(std::process::Stdio::null())
14541            .status();
14542        assert!(
14543            subc_jobobject::wait_for_process_exit(pid, Duration::from_secs(10)),
14544            "could not clean up grandchild {pid}"
14545        );
14546    }
14547}
14548
14549#[cfg(test)]
14550mod privacy_trampoline_configuration_tests {
14551    #[tokio::test]
14552    #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
14553    async fn macos_spawn_without_a_configured_trampoline_refuses_by_name() {
14554        #[cfg(target_os = "macos")]
14555        {
14556            let supervisor = super::Supervisor::new(
14557                std::sync::Arc::new(crate::Registry::default()),
14558                super::RestartPolicy::default(),
14559            );
14560            let error = supervisor.spawn(spec()).unwrap_err();
14561            assert!(
14562                error
14563                    .to_string()
14564                    .contains("no privacy trampoline configured"),
14565                "{error}"
14566            );
14567        }
14568    }
14569
14570    #[tokio::test]
14571    #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
14572    async fn macos_wrong_trampoline_is_rejected_by_the_startup_probe() {
14573        #[cfg(target_os = "macos")]
14574        {
14575            let supervisor = super::Supervisor::new(
14576                std::sync::Arc::new(crate::Registry::default()),
14577                super::RestartPolicy::default(),
14578            )
14579            .with_privacy_trampoline(std::env::current_exe().unwrap());
14580            let error = supervisor.spawn(spec()).unwrap_err();
14581            assert!(
14582                error
14583                    .to_string()
14584                    .contains("binary does not implement the privacy trampoline protocol"),
14585                "{error}"
14586            );
14587        }
14588    }
14589
14590    #[cfg(target_os = "macos")]
14591    fn spec() -> super::ModuleSpec {
14592        super::ModuleSpec {
14593            module_id: "privacy-configuration".into(),
14594            program: "/bin/sleep".into(),
14595            args: vec!["30".into()],
14596            env: vec![],
14597            reserved: false,
14598            reserved_prefixes: vec![],
14599            protocol: subc_control::ModuleProtocol::None,
14600            overlap: super::ModuleOverlap::Exclusive,
14601        }
14602    }
14603}
14604
14605#[cfg(test)]
14606mod privacy_exec_boundary_tests {
14607    #[cfg(target_os = "macos")]
14608    use super::*;
14609    #[cfg(target_os = "macos")]
14610    use std::{
14611        io::{Read, Write},
14612        net::{TcpListener, TcpStream},
14613    };
14614
14615    /// Unit-test-only pause at the actual early image read, not at a later
14616    /// status read. Production supervisors never inspect this environment key.
14617    #[cfg(target_os = "macos")]
14618    pub(super) fn before_image_sample(spec: &ModuleSpec, pid: u32) {
14619        if let Some((_, path)) = spec
14620            .env
14621            .iter()
14622            .find(|(key, _)| key == "SUBC_TEST_PRIVACY_SAMPLE_BARRIER")
14623        {
14624            let mut barrier = TcpStream::connect(path).unwrap();
14625            barrier
14626                .set_read_timeout(Some(Duration::from_secs(30)))
14627                .unwrap();
14628            barrier.write_all(&pid.to_ne_bytes()).unwrap();
14629            let mut release = [0];
14630            barrier.read_exact(&mut release).unwrap();
14631            assert_eq!(&release, b"X");
14632        }
14633    }
14634
14635    #[cfg(target_os = "macos")]
14636    fn spec(program: &str, args: &[&str]) -> ModuleSpec {
14637        ModuleSpec {
14638            module_id: "privacy-boundary".into(),
14639            program: program.into(),
14640            args: args.iter().map(|arg| (*arg).into()).collect(),
14641            env: vec![],
14642            reserved: false,
14643            reserved_prefixes: vec![],
14644            protocol: ModuleProtocol::None,
14645            overlap: ModuleOverlap::Exclusive,
14646        }
14647    }
14648
14649    #[cfg(target_os = "macos")]
14650    async fn accept(listener: TcpListener) -> TcpStream {
14651        // Socket readiness, not elapsed time, establishes both pause points.
14652        let listener = tokio::net::TcpListener::from_std({
14653            listener.set_nonblocking(true).unwrap();
14654            listener
14655        })
14656        .unwrap();
14657        let (stream, _) = tokio::time::timeout(Duration::from_secs(30), listener.accept())
14658            .await
14659            .unwrap()
14660            .unwrap();
14661        let stream = stream.into_std().unwrap();
14662        stream.set_nonblocking(false).unwrap();
14663        stream
14664            .set_read_timeout(Some(Duration::from_secs(30)))
14665            .unwrap();
14666        stream
14667    }
14668
14669    #[tokio::test]
14670    #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
14671    async fn macos_roster_withholds_a_nonnull_trampoline_image_until_exec_confirmation() {
14672        #[cfg(target_os = "macos")]
14673        {
14674            let root = cortexkit_test_support::ScratchDir::new("privacy-roster-barrier");
14675            // Loopback sockets also work when the replay adapter's TMPDIR is
14676            // longer than Darwin's Unix-domain socket path limit.
14677            let exec_listener = TcpListener::bind("127.0.0.1:0").unwrap();
14678            let sample_listener = TcpListener::bind("127.0.0.1:0").unwrap();
14679            let record = root.join("live-children.json");
14680            let supervisor =
14681                Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
14682                    .with_live_children_record(&record);
14683            let runtime = supervisor.runtime_config();
14684            let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
14685            let mut spec = spec("/bin/sleep", &["30"]);
14686            spec.env = vec![
14687                (
14688                    "SUBC_TEST_PRIVACY_EXEC_BARRIER".into(),
14689                    exec_listener.local_addr().unwrap().to_string(),
14690                ),
14691                (
14692                    "SUBC_TEST_PRIVACY_SAMPLE_BARRIER".into(),
14693                    sample_listener.local_addr().unwrap().to_string(),
14694                ),
14695            ];
14696            let spawn = tokio::task::spawn_blocking(move || {
14697                spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap()
14698            });
14699            let mut sample = accept(sample_listener).await;
14700            let mut pid = [0; 4];
14701            sample.read_exact(&mut pid).unwrap();
14702            let pid = u32::from_ne_bytes(pid);
14703            let mut exec = accept(exec_listener).await;
14704            let mut ready = [0];
14705            exec.read_exact(&mut ready).unwrap();
14706            assert_eq!(&ready, b"R");
14707            // The early read is guaranteed to see a real, non-null trampoline
14708            // image: the fixture has reached its barrier and cannot exec yet.
14709            let trampoline = subc_os::file_identity(&test_privacy_trampoline()).unwrap();
14710            assert_eq!(
14711                observe_spawned_image(pid).unwrap().executable,
14712                Some(trampoline)
14713            );
14714            sample.write_all(b"X").unwrap();
14715            let mut child = spawn.await.unwrap();
14716            let early = crate::live_children::read_record(&record).unwrap();
14717            assert_eq!(early.len(), 1);
14718            assert_eq!(early[0].pid, pid);
14719            assert_eq!(
14720                early[0].executable, None,
14721                "unconfirmed trampoline image entered the roster"
14722            );
14723            assert!(child.report_ready.get().is_none());
14724            // The barrier's duration is unrelated to the production five-second
14725            // exec budget. Start the test's confirmation budget upon release.
14726            child.privacy_exec.as_mut().unwrap().deadline =
14727                tokio::time::Instant::now() + Duration::from_secs(30);
14728            exec.write_all(b"X").unwrap();
14729            child.confirm_privacy_exec().await;
14730            assert_eq!(child.spawn_failure, None);
14731            assert!(child.report_ready.get().is_some());
14732            let confirmed = crate::live_children::read_record(&record).unwrap();
14733            let module = subc_os::file_identity(std::path::Path::new("/bin/sleep")).unwrap();
14734            assert_ne!(module, trampoline);
14735            assert_eq!(confirmed[0].executable, Some(module.into()));
14736            child.start_kill().unwrap();
14737            child.wait().await.unwrap();
14738            child.release_roster();
14739        }
14740    }
14741
14742    #[tokio::test]
14743    #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
14744    async fn macos_already_exited_121_in_try_wait_is_a_module_exit_not_a_trampoline_refusal() {
14745        #[cfg(target_os = "macos")]
14746        {
14747            let registry = Arc::new(Registry::default());
14748            let policy = RestartPolicy::new(0, Duration::ZERO);
14749            let supervisor = Supervisor::new_for_test(Arc::clone(&registry), policy);
14750            let runtime = supervisor.runtime_config();
14751            let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
14752            let spec = spec("/bin/sh", &["-c", "exit 121"]);
14753            // Drive spawn and confirmation separately instead of starting the
14754            // monitor. WNOWAIT observes a real exit without consuming its status,
14755            // so confirmation's first try_wait must take the already-exited arm.
14756            let mut child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
14757            let pid = child.pid;
14758            tokio::task::spawn_blocking(move || {
14759                subc_os::fork_exec_test::wait_for_child_exit_without_reaping(pid)
14760            })
14761            .await
14762            .unwrap()
14763            .unwrap();
14764            child.privacy_exec.as_mut().unwrap().deadline =
14765                tokio::time::Instant::now() + Duration::from_secs(30);
14766            let status = child.wait().await.unwrap();
14767            assert_eq!(status.code(), Some(121));
14768            assert!(child.privacy_exec.is_none());
14769            assert!(
14770                child.report_ready.get().is_none(),
14771                "an exited module must not publish a live pid"
14772            );
14773            let report = classify_reaped_child_exit(&snapshot, &child, &status);
14774            on_child_exit(
14775                &spec,
14776                policy,
14777                &registry,
14778                &snapshot,
14779                &runtime.terminal_ring,
14780                &runtime.spawn_events,
14781                &runtime.child_roster,
14782                report,
14783            )
14784            .await;
14785            let state = lock_snapshot(&snapshot).unwrap();
14786            assert_eq!(state.state, ModuleState::Failed);
14787            assert_eq!(state.last_exit.as_ref().unwrap().code, Some(121));
14788            assert_eq!(state.reported_pid(), None);
14789            drop(state);
14790            let history = runtime.terminal_ring.lock().unwrap().snapshot();
14791            assert_eq!(history.entries.len(), 1);
14792            let terminal = &history.entries[0];
14793            assert_eq!(terminal.exit_code, Some(121));
14794            assert_eq!(terminal.exit_kind, subc_control::TerminalExitKind::Crash);
14795            assert_eq!(terminal.disposition, TerminalDisposition::Failed);
14796            assert_eq!(
14797                terminal.disposition_detail.as_deref(),
14798                Some(policy.budget_exhausted_detail().as_str()),
14799                "module exit 121 was classified as a trampoline refusal: {terminal:?}"
14800            );
14801            assert_eq!(child.spawn_failure, None);
14802            child.release_roster();
14803        }
14804    }
14805}
14806
14807/// The daemon's real spawn path hands a subc-wire child its launch nonce on
14808/// descriptor 3, without an environment copy. The shell records the nonce
14809/// and its environment after exec so these tests observe the real handover.
14810#[cfg(all(test, unix))]
14811mod launch_nonce_descriptor_tests {
14812    use super::{ChildRoster, ModuleProtocol, ModuleSpec, SupervisorHandle};
14813    use crate::stderr_tail::{StderrRing, StderrTailConfig};
14814    use cortexkit_test_support::ScratchDir;
14815    use std::{
14816        path::PathBuf,
14817        sync::{Arc, Mutex},
14818        time::{Duration, Instant},
14819    };
14820
14821    async fn probe(role: super::SpawnRole) {
14822        let scratch = ScratchDir::new("launch-nonce-descriptor");
14823        let fd_copy = scratch.join("from-descriptor");
14824        let env_copy = scratch.join("environment");
14825        let script = format!(
14826            "env > '{env}.tmp' && mv '{env}.tmp' '{env}'; cat <&3 > '{fd}.tmp' && mv '{fd}.tmp' '{fd}'; sleep 30",
14827            fd = fd_copy.display(), env = env_copy.display(),
14828        );
14829        let xdg = |name: &str| (name.to_string(), scratch.join(name).display().to_string());
14830        let spec = ModuleSpec {
14831            module_id: "nonce-descriptor-probe".to_string(),
14832            program: PathBuf::from("/bin/sh"),
14833            args: vec!["-c".to_string(), script],
14834            env: vec![
14835                xdg("XDG_DATA_HOME"),
14836                xdg("XDG_RUNTIME_DIR"),
14837                xdg("XDG_CONFIG_HOME"),
14838                ("SUBC_LAUNCH_NONCE".to_string(), "must-not-leak".to_string()),
14839            ],
14840            reserved: true,
14841            reserved_prefixes: Vec::new(),
14842            protocol: ModuleProtocol::Subc,
14843            overlap: Default::default(),
14844        };
14845        let handle = SupervisorHandle::new();
14846        let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
14847        let roster = ChildRoster::default();
14848        #[cfg(target_os = "macos")]
14849        {
14850            let path = super::test_privacy_trampoline();
14851            roster.set_privacy_trampoline(super::probe_privacy_trampoline(&path).map(|()| path));
14852        }
14853        let child = super::spawn_child_in_slot(
14854            &spec,
14855            None,
14856            Some(&handle),
14857            &ring,
14858            None,
14859            &roster,
14860            #[cfg(target_os = "linux")]
14861            None,
14862            role,
14863            matches!(role, super::SpawnRole::SwapCandidate),
14864        )
14865        .expect("spawn probe");
14866        let deadline = Instant::now() + Duration::from_secs(10);
14867        while !(fd_copy.exists() && env_copy.exists()) {
14868            assert!(Instant::now() < deadline, "probe never wrote its copies");
14869            tokio::time::sleep(Duration::from_millis(20)).await;
14870        }
14871        let nonce = std::fs::read_to_string(fd_copy).unwrap();
14872        assert!(!nonce.is_empty());
14873        let environment = std::fs::read_to_string(env_copy).unwrap();
14874        assert!(environment
14875            .lines()
14876            .any(|line| line.starts_with("SUBC_LAUNCH_NONCE_FD=3:")));
14877        let copy = environment
14878            .lines()
14879            .find_map(|line| line.strip_prefix("SUBC_LAUNCH_NONCE="));
14880        assert_eq!(
14881            copy, None,
14882            "Unix children must never receive the environment nonce"
14883        );
14884        if matches!(role, super::SpawnRole::Plain) {
14885            assert_eq!(
14886                handle.spawn_nonce(&spec.module_id).as_deref(),
14887                Some(nonce.as_str())
14888            );
14889        }
14890        drop(child);
14891    }
14892
14893    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
14894    async fn a_spawned_module_receives_its_nonce_only_on_descriptor_3() {
14895        probe(super::SpawnRole::Plain).await;
14896    }
14897
14898    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
14899    async fn swap_candidate_receives_its_nonce_only_on_descriptor_3() {
14900        probe(super::SpawnRole::SwapCandidate).await;
14901    }
14902}
14903
14904#[cfg(all(test, target_os = "linux"))]
14905mod cgroup_containment_tests {
14906    use super::*;
14907    use cortexkit_test_support::ScratchDir;
14908
14909    fn running(pid: u32) -> bool {
14910        // An orphan can remain a zombie until the container init reaps it.
14911        std::fs::read_to_string(format!("/proc/{pid}/stat"))
14912            .ok()
14913            .and_then(|stat| {
14914                stat.rsplit_once(") ")
14915                    .map(|(_, rest)| rest.starts_with('Z'))
14916            })
14917            .is_some_and(|zombie| !zombie)
14918    }
14919
14920    #[tokio::test]
14921    async fn linux_teardown_reaps_the_grandchild() {
14922        teardown_tree("linux_teardown_reaps_the_grandchild", false).await;
14923    }
14924
14925    #[tokio::test]
14926    async fn linux_shutdown_straggler_reaps_the_grandchild() {
14927        teardown_tree("linux_shutdown_straggler_reaps_the_grandchild", true).await;
14928    }
14929
14930    async fn teardown_tree(test_name: &str, shutdown: bool) {
14931        let dir = ScratchDir::new(test_name);
14932        let root = PathBuf::from(format!(
14933            "/sys/fs/cgroup/subc-tree-test-{test_name}-{}-{}",
14934            std::process::id(),
14935            unix_ms_now()
14936        ));
14937        if let Err(error) = std::fs::create_dir(&root) {
14938            assert!(
14939                std::env::var_os("SUBC_REQUIRE_CGROUP_TEST").is_none(),
14940                "required cgroup test cannot execute: {error}"
14941            );
14942            eprintln!(
14943                "SKIP {test_name}: no writable delegated cgroup at {}: {error}",
14944                root.display()
14945            );
14946            return;
14947        }
14948        let placement = subc_cgroup::prepare_at(&root)
14949            .expect("prepare isolated kernel cgroup")
14950            .expect("isolated cgroup is delegated");
14951        let module_id = "tree-teardown";
14952        let module = placement
14953            .module_path(module_id)
14954            .expect("create isolated module cgroup");
14955        if !module.join("cgroup.kill").exists() {
14956            std::fs::remove_dir(&module).unwrap();
14957            std::fs::remove_dir(root.join("subc-modules")).unwrap();
14958            std::fs::remove_dir(&root).unwrap();
14959            assert!(
14960                std::env::var_os("SUBC_REQUIRE_CGROUP_TEST").is_none(),
14961                "required cgroup.kill interface unavailable"
14962            );
14963            eprintln!("SKIP {test_name}: cgroup.kill unavailable (kernel < 5.14)");
14964            return;
14965        }
14966        let supervisor = Supervisor::new_for_test(
14967            Arc::new(Registry::default()),
14968            RestartPolicy::new(3, Duration::ZERO),
14969        )
14970        .with_cgroup_placement(Some(placement));
14971        let mut runtime = supervisor.runtime_config();
14972        runtime.child_roster = runtime
14973            .child_roster
14974            .for_module(Arc::new(Mutex::new(Duration::from_millis(100))));
14975        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
14976        let pid_file = dir.join("grandchild.pid");
14977        let spec = ModuleSpec {
14978            module_id: module_id.to_string(),
14979            program: PathBuf::from("/bin/sh"),
14980            args: vec![
14981                "-c".into(),
14982                "trap '' TERM; sleep 600 & echo $! > \"$1\"; wait".into(),
14983                "fixture".into(),
14984                pid_file.display().to_string(),
14985            ],
14986            env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
14987                .into_iter()
14988                .map(|key| (key.to_string(), dir.display().to_string()))
14989                .collect(),
14990            reserved: false,
14991            reserved_prefixes: Vec::new(),
14992            protocol: ModuleProtocol::None,
14993            overlap: Default::default(),
14994        };
14995        let child =
14996            spawn_and_mark_running(&spec, &runtime, &snapshot).expect("spawn supervised tree");
14997        let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
14998        let grandchild: u32 = loop {
14999            if let Ok(contents) = std::fs::read_to_string(&pid_file) {
15000                if let Ok(pid) = contents.trim().parse() {
15001                    break pid;
15002                }
15003            }
15004            assert!(
15005                tokio::time::Instant::now() < deadline,
15006                "grandchild pid was not recorded"
15007            );
15008            tokio::time::sleep(Duration::from_millis(10)).await;
15009        };
15010        assert!(
15011            running(grandchild),
15012            "grandchild must be alive before teardown"
15013        );
15014        if shutdown {
15015            let mut child = child;
15016            crate::child_roster::end_children_for_daemon_shutdown(
15017                &runtime.child_roster,
15018                false,
15019                std::future::pending(),
15020            )
15021            .await;
15022            child.wait().await.expect("reap shutdown straggler");
15023        } else {
15024            drain_child_to_state(
15025                module_id,
15026                ModuleProtocol::None,
15027                StopNotice::NotSent,
15028                &Registry::default(),
15029                None,
15030                &snapshot,
15031                &runtime.terminal_ring,
15032                &SpawnEventFeed::default(),
15033                child,
15034                Duration::from_millis(100),
15035                ModuleState::Stopped,
15036                Some(false),
15037            )
15038            .await
15039            .expect("real supervisor teardown");
15040        }
15041        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
15042        while running(grandchild) && tokio::time::Instant::now() < deadline {
15043            tokio::time::sleep(Duration::from_millis(10)).await;
15044        }
15045        let survived = running(grandchild);
15046        // Kill a surviving grandchild so a failed test does not leave it behind.
15047        if survived {
15048            let pid = rustix::process::Pid::from_raw(grandchild as i32).unwrap();
15049            let _ = rustix::process::kill_process(pid, rustix::process::Signal::KILL);
15050            tokio::time::sleep(Duration::from_millis(100)).await;
15051        }
15052        if module.exists() {
15053            std::fs::remove_dir(&module).expect("remove empty module cgroup");
15054        }
15055        std::fs::remove_dir(root.join("subc-modules")).unwrap();
15056        std::fs::remove_dir(&root).unwrap();
15057        assert!(
15058            !survived,
15059            "grandchild {grandchild} outlived module teardown"
15060        );
15061        eprintln!("EXECUTED {test_name}: grandchild {grandchild} killed in isolated cgroup");
15062    }
15063}