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    /// Crash restarts still inside `window`, having dropped the ones that are
1179    /// not. Pruning on read is what makes the budget a rate: an instant older
1180    /// than the window stops holding a slot the moment anybody counts.
1181    fn crash_restarts_in_window(&mut self, window: Duration, now: Instant) -> u32 {
1182        while let Some(oldest) = self.crash_restarts.front() {
1183            if now.duration_since(*oldest) > window {
1184                self.crash_restarts.pop_front();
1185            } else {
1186                break;
1187            }
1188        }
1189        u32::try_from(self.crash_restarts.len()).unwrap_or(u32::MAX)
1190    }
1191
1192    /// Spend one unit of the crash budget and record the restart in the ledger.
1193    ///
1194    /// The ring is bounded by the cap because more than `max_restarts` in-window
1195    /// instants can never be reached (the caller refuses the restart first), so
1196    /// anything beyond that is an unbounded queue waiting to happen.
1197    fn record_crash_restart(&mut self, policy: &RestartPolicy, now: Instant) {
1198        self.crash_restarts.push_back(now);
1199        while self.crash_restarts.len() > policy.max_restarts as usize {
1200            self.crash_restarts.pop_front();
1201        }
1202        self.lifetime_restarts += 1;
1203    }
1204
1205    /// Reserve one crash-restart slot and calculate the delay before respawning.
1206    /// The count is captured before recording this restart, so the first retry
1207    /// uses the base delay and each later in-window retry escalates once.
1208    fn next_crash_restart(
1209        &mut self,
1210        policy: &RestartPolicy,
1211        now: Instant,
1212    ) -> Option<CrashRestartSchedule> {
1213        let restart_in_window = self.crash_restarts_in_window(policy.window, now);
1214        if restart_in_window >= policy.max_restarts {
1215            return None;
1216        }
1217        self.record_crash_restart(policy, now);
1218        Some(CrashRestartSchedule {
1219            restart_in_window,
1220            delay: policy.delay_for_restart(restart_in_window),
1221        })
1222    }
1223
1224    /// Give the module its full budget back, as an operator restart, reload, or
1225    /// re-enable does. `lifetime_restarts` deliberately does not move: it is the
1226    /// ledger of what actually happened, and an operator action does not unmake
1227    /// the crashes.
1228    fn clear_crash_restarts(&mut self) {
1229        self.crash_restarts.clear();
1230    }
1231
1232    fn new(state: ModuleState, enabled: bool) -> Self {
1233        Self {
1234            #[cfg(test)]
1235            actor_turns: 0,
1236            #[cfg(test)]
1237            actor_select: None,
1238            state,
1239            enabled,
1240            process_alive: false,
1241            #[cfg(windows)]
1242            pipe_consumed: None,
1243            spawned_protocol: None,
1244            spawn_failure: None,
1245            waiting_for_previous_pid: None,
1246            crash_restarts: VecDeque::new(),
1247            lifetime_restarts: 0,
1248            spawn_generation: 0,
1249            pid: None,
1250            #[cfg(target_os = "macos")]
1251            report_ready: None,
1252            reaped_pid: None,
1253            respawn_pending: false,
1254            coalesced_restart_pending: false,
1255            spawned_at_ms: None,
1256            spawned_from: None,
1257            spawned_file_identity: None,
1258            process_start_time: None,
1259            deliberate_severance: None,
1260            last_exit: None,
1261            drain_disposition_detail: None,
1262            health: ModuleHealthStatus::default(),
1263            in_alternate_slot: false,
1264            draining_to_replace: false,
1265            configuration_updated_since_spawn: false,
1266        }
1267    }
1268}
1269
1270type SharedSnapshot = Arc<Mutex<SupervisorSnapshot>>;
1271
1272type SpawnSubscriberKey = (ConnectionId, u64);
1273
1274#[derive(Debug)]
1275struct SpawnSubscriber {
1276    version: u8,
1277    frames: mpsc::Sender<Frame>,
1278    /// Tells this subscriber's forwarder that it was dropped for lagging, and
1279    /// from which event. The full frame channel cannot carry that news, so it
1280    /// travels beside it; see `SpawnEventFeed::subscribe`.
1281    lagged: Option<oneshot::Sender<SpawnCursor>>,
1282}
1283
1284#[derive(Debug)]
1285struct SpawnEventState {
1286    daemon_incarnation: String,
1287    seq: u64,
1288    capacity: usize,
1289    live: HashMap<String, LiveSpawn>,
1290    generations: HashMap<String, u64>,
1291    events: VecDeque<SpawnEvent>,
1292    subscribers: HashMap<SpawnSubscriberKey, SpawnSubscriber>,
1293}
1294
1295impl Default for SpawnEventState {
1296    fn default() -> Self {
1297        Self {
1298            daemon_incarnation: "unconfigured".to_string(),
1299            seq: 0,
1300            capacity: SPAWN_EVENT_RING_CAPACITY,
1301            live: HashMap::new(),
1302            generations: HashMap::new(),
1303            events: VecDeque::new(),
1304            subscribers: HashMap::new(),
1305        }
1306    }
1307}
1308
1309#[derive(Debug, Clone, Default)]
1310struct SpawnEventFeed(
1311    Arc<Mutex<SpawnEventState>>,
1312    #[cfg(test)] Arc<Mutex<Option<std::net::TcpStream>>>,
1313);
1314
1315#[derive(Debug, Clone, PartialEq, Eq)]
1316pub(crate) enum SpawnSubscribeRefusal {
1317    ForeignIncarnation { current: String },
1318    TooOld { oldest: SpawnCursor },
1319    Frame(String),
1320}
1321
1322impl SpawnEventFeed {
1323    fn configure_incarnation(&self, daemon_incarnation: String) {
1324        let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1325        state.daemon_incarnation = daemon_incarnation;
1326        state.seq = 0;
1327        state.live.clear();
1328        state.generations.clear();
1329        state.events.clear();
1330        state.subscribers.clear();
1331    }
1332
1333    fn cursor(state: &SpawnEventState) -> SpawnCursor {
1334        SpawnCursor {
1335            daemon_incarnation: state.daemon_incarnation.clone(),
1336            seq: state.seq,
1337        }
1338    }
1339
1340    fn snapshot(&self) -> SpawnSnapshot {
1341        let state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1342        let mut live = state.live.values().cloned().collect::<Vec<_>>();
1343        live.sort_by(|left, right| left.module_id.cmp(&right.module_id));
1344        SpawnSnapshot {
1345            cursor: Self::cursor(&state),
1346            ring_bound: state.capacity as u64,
1347            live,
1348        }
1349    }
1350
1351    fn emit_spawned(&self, module_id: &str, pid: u32, spawned_at_ms: u64) -> u64 {
1352        let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1353        let generation = state
1354            .generations
1355            .get(module_id)
1356            .copied()
1357            .unwrap_or(0)
1358            .checked_add(1)
1359            .expect("spawn generation exhausted");
1360        state.generations.insert(module_id.to_string(), generation);
1361        let live = LiveSpawn {
1362            module_id: module_id.to_string(),
1363            spawn_generation: generation,
1364            pid,
1365            spawned_at_ms,
1366        };
1367        state.live.insert(module_id.to_string(), live);
1368        Self::emit_locked(
1369            &mut state,
1370            SpawnEventKind::Spawned,
1371            module_id.to_string(),
1372            generation,
1373            pid,
1374            None,
1375            None,
1376        );
1377        generation
1378    }
1379
1380    fn emit_exited(&self, module_id: &str, exit_code: Option<i32>, exit_signal: Option<i32>) {
1381        let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1382        let Some(live) = state.live.remove(module_id) else {
1383            warn!(
1384                module_id,
1385                "terminal record had no live spawn event identity"
1386            );
1387            return;
1388        };
1389        Self::emit_locked(
1390            &mut state,
1391            SpawnEventKind::Exited,
1392            module_id.to_string(),
1393            live.spawn_generation,
1394            live.pid,
1395            exit_code,
1396            exit_signal,
1397        );
1398    }
1399
1400    /// Report the exit of a process that a swap has already replaced.
1401    ///
1402    /// `emit_exited` removes the module's live entry, which after a swap's
1403    /// cutover describes the promoted candidate, not the old process now
1404    /// exiting. This emits the old generation's exit and leaves the live entry
1405    /// alone unless it still names that generation.
1406    fn emit_superseded_exited(
1407        &self,
1408        module_id: &str,
1409        spawn_generation: u64,
1410        pid: u32,
1411        exit_code: Option<i32>,
1412        exit_signal: Option<i32>,
1413    ) {
1414        let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1415        if state
1416            .live
1417            .get(module_id)
1418            .is_some_and(|live| live.spawn_generation == spawn_generation)
1419        {
1420            state.live.remove(module_id);
1421        }
1422        Self::emit_locked(
1423            &mut state,
1424            SpawnEventKind::Exited,
1425            module_id.to_string(),
1426            spawn_generation,
1427            pid,
1428            exit_code,
1429            exit_signal,
1430        );
1431    }
1432
1433    #[allow(clippy::too_many_arguments)]
1434    fn emit_locked(
1435        state: &mut SpawnEventState,
1436        kind: SpawnEventKind,
1437        module_id: String,
1438        spawn_generation: u64,
1439        pid: u32,
1440        exit_code: Option<i32>,
1441        exit_signal: Option<i32>,
1442    ) {
1443        state.seq = state
1444            .seq
1445            .checked_add(1)
1446            .expect("spawn event sequence exhausted");
1447        let event = SpawnEvent {
1448            cursor: Self::cursor(state),
1449            kind,
1450            module_id,
1451            spawn_generation,
1452            pid,
1453            exit_code,
1454            exit_signal,
1455        };
1456        state.events.push_back(event.clone());
1457        while state.events.len() > state.capacity {
1458            state.events.pop_front();
1459        }
1460        let body = match serde_json::to_vec(&event) {
1461            Ok(body) => body,
1462            Err(error) => {
1463                error!(%error, "failed to serialize supervisor spawn event");
1464                return;
1465            }
1466        };
1467        state.subscribers.retain(|(connection_id, corr), subscriber| {
1468            let frame = Frame::build_with_version(
1469                subscriber.version,
1470                FrameType::StreamData,
1471                control_flags(),
1472                0,
1473                0,
1474                *corr,
1475                body.clone(),
1476            );
1477            match frame {
1478                Ok(frame) => {
1479                    if subscriber.frames.try_send(frame).is_ok() {
1480                        true
1481                    } else {
1482                        warn!(connection_id = connection_id.get(), corr, "dropping lagged supervisor spawn subscriber");
1483                        if let Some(lagged) = subscriber.lagged.take() {
1484                            let _ = lagged.send(event.cursor.clone());
1485                        }
1486                        false
1487                    }
1488                }
1489                Err(error) => {
1490                    warn!(connection_id = connection_id.get(), corr, %error, "dropping supervisor spawn subscriber after frame build failure");
1491                    false
1492                }
1493            }
1494        });
1495    }
1496
1497    fn subscribe(
1498        &self,
1499        connection_id: ConnectionId,
1500        corr: u64,
1501        version: u8,
1502        since: Option<SpawnCursor>,
1503        sink: FrameSink,
1504    ) -> Result<(), SpawnSubscribeRefusal> {
1505        let (frames, mut receiver) = mpsc::channel(SPAWN_SUBSCRIBER_BUFFER);
1506        let (lagged, mut lagged_rx) = oneshot::channel::<SpawnCursor>();
1507        {
1508            let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1509            let replay = if let Some(since) = since {
1510                if since.daemon_incarnation != state.daemon_incarnation {
1511                    return Err(SpawnSubscribeRefusal::ForeignIncarnation {
1512                        current: state.daemon_incarnation.clone(),
1513                    });
1514                }
1515                if let Some(oldest) = state.events.front().map(|event| event.cursor.clone()) {
1516                    if since.seq < oldest.seq.saturating_sub(1) {
1517                        return Err(SpawnSubscribeRefusal::TooOld { oldest });
1518                    }
1519                }
1520                state
1521                    .events
1522                    .iter()
1523                    .filter(|event| event.cursor.seq > since.seq)
1524                    .cloned()
1525                    .collect::<Vec<_>>()
1526            } else {
1527                Vec::new()
1528            };
1529            for event in replay {
1530                let body = serde_json::to_vec(&event)
1531                    .map_err(|error| SpawnSubscribeRefusal::Frame(error.to_string()))?;
1532                let frame = Frame::build_with_version(
1533                    version,
1534                    FrameType::StreamData,
1535                    control_flags(),
1536                    0,
1537                    0,
1538                    corr,
1539                    body,
1540                )
1541                .map_err(|error| SpawnSubscribeRefusal::Frame(error.to_string()))?;
1542                frames
1543                    .try_send(frame)
1544                    .map_err(|error| SpawnSubscribeRefusal::Frame(error.to_string()))?;
1545            }
1546            state.subscribers.insert(
1547                (connection_id, corr),
1548                SpawnSubscriber {
1549                    version,
1550                    frames,
1551                    lagged: Some(lagged),
1552                },
1553            );
1554        }
1555        // The lagged terminal is sent here, by the forwarder, rather than by
1556        // the emitter: at the moment of the drop the subscriber's own channel
1557        // is full, and writing to the connection sink directly from the emitter
1558        // would put the Error AHEAD of the events still queued in that channel
1559        // (and the emitter holds the feed lock, so it cannot await the sink).
1560        // Dropping the subscriber drops the only sender, so `recv` drains every
1561        // queued event and then returns `None`; only then is the Error sent, so
1562        // the client sees each event it can keep, then the reason it was cut.
1563        // Cancel and connection removal drop the oneshot unsent, so they end
1564        // the stream with no Error.
1565        tokio::spawn(async move {
1566            while let Some(frame) = receiver.recv().await {
1567                if sink.send(frame).await.is_err() {
1568                    return;
1569                }
1570            }
1571            let Ok(first_undelivered) = lagged_rx.try_recv() else {
1572                return;
1573            };
1574            match spawn_subscriber_lagged_frame(version, corr, first_undelivered) {
1575                Ok(frame) => {
1576                    let _ = sink.send(frame).await;
1577                }
1578                Err(error) => {
1579                    error!(%error, corr, "failed to build lagged spawn subscriber terminal frame");
1580                }
1581            }
1582        });
1583        Ok(())
1584    }
1585
1586    fn cancel(&self, connection_id: ConnectionId, corr: u64) -> bool {
1587        let Some(subscriber) = self
1588            .0
1589            .lock()
1590            .unwrap_or_else(|p| p.into_inner())
1591            .subscribers
1592            .remove(&(connection_id, corr))
1593        else {
1594            return false;
1595        };
1596        if let Ok(frame) = Frame::build_with_version(
1597            subscriber.version,
1598            FrameType::StreamEnd,
1599            control_flags(),
1600            0,
1601            0,
1602            corr,
1603            Vec::new(),
1604        ) {
1605            tokio::spawn(async move {
1606                let _ = subscriber.frames.send(frame).await;
1607            });
1608        }
1609        true
1610    }
1611
1612    fn remove_connection(&self, connection_id: ConnectionId) {
1613        self.0
1614            .lock()
1615            .unwrap_or_else(|p| p.into_inner())
1616            .subscribers
1617            .retain(|(subscriber_connection, _), _| *subscriber_connection != connection_id);
1618    }
1619
1620    #[cfg(any(test, feature = "test-support"))]
1621    fn set_capacity(&self, capacity: usize) {
1622        self.0.lock().unwrap_or_else(|p| p.into_inner()).capacity = capacity;
1623    }
1624
1625    #[cfg(any(test, feature = "test-support"))]
1626    fn subscriber_count(&self) -> usize {
1627        self.0
1628            .lock()
1629            .unwrap_or_else(|p| p.into_inner())
1630            .subscribers
1631            .len()
1632    }
1633}
1634
1635/// Narrow process-liveness signal published by supervisors and consumed by passive liveness polls.
1636/// The terminal Error a lagged spawn subscriber receives after its queued events.
1637fn spawn_subscriber_lagged_frame(
1638    version: u8,
1639    corr: u64,
1640    first_undelivered: SpawnCursor,
1641) -> Result<Frame, String> {
1642    let body = serde_json::to_vec(&subc_protocol::ErrorBody {
1643        code: SPAWN_SUBSCRIBER_LAGGED_CODE.to_string(),
1644        message: "spawn subscriber fell behind and was dropped; resubscribe from the last cursor received"
1645            .to_string(),
1646        detail: Some(serde_json::json!({
1647            "first_undelivered_cursor": first_undelivered
1648        })),
1649    })
1650    .map_err(|error| error.to_string())?;
1651    Frame::build_with_version(version, FrameType::Error, control_flags(), 0, 0, corr, body)
1652        .map_err(|error| error.to_string())
1653}
1654
1655pub trait ModuleProcessLiveness: Send + Sync {
1656    fn process_live(&self, module_id: &str) -> Option<bool>;
1657
1658    /// Whether the supervisor is replacing this module's process right now: an
1659    /// operator restart or reload, a health restart, or a crash respawn whose
1660    /// backoff is running. A module in that state is not live, but a consumer
1661    /// refused now should retry shortly rather than treat the target as gone.
1662    /// Stopped, failed, and disabled modules are not replacing.
1663    fn process_replacing(&self, _module_id: &str) -> bool {
1664        false
1665    }
1666}
1667
1668/// Shared process-liveness registry keyed by supervised `module_id`.
1669#[derive(Debug, Clone, Default)]
1670pub struct SupervisorProcessLiveness {
1671    snapshots: Arc<Mutex<HashMap<String, SharedSnapshot>>>,
1672}
1673
1674impl SupervisorProcessLiveness {
1675    pub fn new() -> Self {
1676        Self::default()
1677    }
1678
1679    fn track(&self, module_id: String, snapshot: SharedSnapshot) {
1680        let mut snapshots = self
1681            .snapshots
1682            .lock()
1683            .unwrap_or_else(|poisoned| poisoned.into_inner());
1684        snapshots.insert(module_id, snapshot);
1685    }
1686
1687    fn untrack_if_current(&self, module_id: &str, snapshot: &SharedSnapshot) {
1688        let mut snapshots = self
1689            .snapshots
1690            .lock()
1691            .unwrap_or_else(|poisoned| poisoned.into_inner());
1692        let is_current = snapshots
1693            .get(module_id)
1694            .map(|tracked| Arc::ptr_eq(tracked, snapshot))
1695            .unwrap_or(false);
1696        if is_current {
1697            snapshots.remove(module_id);
1698        }
1699    }
1700}
1701
1702impl ModuleProcessLiveness for SupervisorProcessLiveness {
1703    fn process_live(&self, module_id: &str) -> Option<bool> {
1704        let snapshot = {
1705            let snapshots = self
1706                .snapshots
1707                .lock()
1708                .unwrap_or_else(|poisoned| poisoned.into_inner());
1709            snapshots.get(module_id).cloned()
1710        }?;
1711        let snapshot = snapshot
1712            .lock()
1713            .unwrap_or_else(|poisoned| poisoned.into_inner());
1714        Some(snapshot.state == ModuleState::Running && snapshot.process_alive)
1715    }
1716
1717    fn process_replacing(&self, module_id: &str) -> bool {
1718        let Some(snapshot) = self
1719            .snapshots
1720            .lock()
1721            .unwrap_or_else(|poisoned| poisoned.into_inner())
1722            .get(module_id)
1723            .cloned()
1724        else {
1725            return false;
1726        };
1727        let snapshot = snapshot
1728            .lock()
1729            .unwrap_or_else(|poisoned| poisoned.into_inner());
1730        snapshot.enabled
1731            && match snapshot.state {
1732                ModuleState::Restarting => true,
1733                ModuleState::Draining => snapshot.draining_to_replace,
1734                ModuleState::Starting
1735                | ModuleState::Running
1736                | ModuleState::Unresponsive
1737                | ModuleState::Stopped
1738                | ModuleState::Failed
1739                | ModuleState::Disabled => false,
1740            }
1741    }
1742}
1743
1744#[cfg(test)]
1745#[derive(Debug, Default)]
1746struct ReloadExitRecordGate {
1747    reached: tokio::sync::Notify,
1748    resume: tokio::sync::Notify,
1749}
1750
1751#[derive(Debug, Clone, Copy)]
1752enum RespawnKind {
1753    Spawn,
1754    Reload,
1755}
1756
1757#[derive(Debug, Clone, Copy)]
1758struct PendingRespawn {
1759    deadline: Instant,
1760    kind: RespawnKind,
1761}
1762
1763type ReloadReply = oneshot::Sender<Result<(), SuperviseError>>;
1764
1765#[derive(Debug, Clone)]
1766struct SupervisorRuntimeConfig {
1767    start_requester: Option<Principal>,
1768    pending_activity_restart: bool,
1769    provenance_probe: ExecutableIdentityProbe,
1770    /// Restart operations hand their backoff to the loop, which keeps serving commands.
1771    scheduled_respawn: Arc<Mutex<Option<PendingRespawn>>>,
1772    /// A reload acknowledges completion only after its replacement registers.
1773    deferred_reload_reply: Arc<Mutex<Option<ReloadReply>>>,
1774    restart_policy: RestartPolicy,
1775    /// This module's RESOLVED drain budget: per-module config when present,
1776    /// else `default_drain_timeout`.
1777    drain_timeout: Duration,
1778    /// Shared with the status handle so the attested value changes atomically
1779    /// when a rescan updates the running drain policy.
1780    effective_drain_timeout: Arc<Mutex<Duration>>,
1781    /// The supervisor-wide fallback, kept so a configuration update that
1782    /// REMOVES the per-module override can re-resolve to it.
1783    default_drain_timeout: Duration,
1784    health: HealthConfig,
1785    connection_file_path: Option<PathBuf>,
1786    capture_logs_dir: Option<PathBuf>,
1787    forwarding: Option<Arc<ForwardingTable>>,
1788    /// The shared handle, so every spawn path (initial, restart, reload) records the
1789    /// reserved-module launch nonce the HELLO verifier checks against.
1790    supervisor_handle: Option<SupervisorHandle>,
1791    /// This module's stderr tail, shared with the [`SupervisedModule`] that answers
1792    /// status queries.
1793    ///
1794    /// One ring per module, held across every respawn. The lines explaining an exit
1795    /// are written BEFORE that exit, so a ring recreated per process would be empty
1796    /// exactly when it is asked for.
1797    stderr_ring: Arc<Mutex<StderrRing>>,
1798    terminal_ring: Arc<Mutex<TerminalRing>>,
1799    spawn_events: SpawnEventFeed,
1800    child_roster: ChildRoster,
1801    #[cfg(target_os = "linux")]
1802    cgroup_placement: Option<subc_cgroup::Placement>,
1803    #[cfg(test)]
1804    test_seed_stale_facts_before_enable_spawn: bool,
1805    #[cfg(test)]
1806    test_reload_exit_record_gate: Option<Arc<ReloadExitRecordGate>>,
1807}
1808
1809#[derive(Debug, Clone, PartialEq, Eq)]
1810struct SupervisedConfiguration {
1811    spec: ModuleSpec,
1812    health: HealthConfig,
1813}
1814
1815/// Shared daemon lookup table for supervised module handles.
1816///
1817/// Shared by clone between the [`Supervisor`] (which spawns processes) and the
1818/// channel-0 control handler (which verifies HELLOs and consumer route opens), so
1819/// launch nonces recorded at spawn are checked by the same daemon instance.
1820#[derive(Debug, Clone, Default)]
1821pub struct SupervisorHandle {
1822    activity: Arc<Mutex<Option<crate::activity::ActivityLedger>>>,
1823    activity_images: Arc<Mutex<HashMap<String, activity::ActivityImage>>>,
1824    modules: Arc<Mutex<HashMap<String, SupervisedModule>>>,
1825    /// Module ids the supervisor has taken on. An id is added BEFORE the
1826    /// module's first process is spawned and removed only when the module
1827    /// leaves the roster (see [`Self::retire`]), so it is always a superset of
1828    /// the keys of `modules`.
1829    ///
1830    /// `modules` cannot answer "is this module configured?" on its own: a
1831    /// [`SupervisedModule`] only exists once its process has been spawned, and
1832    /// a fast child can connect, register, sync its scopes and ask about them
1833    /// before the supervisor has inserted it. Answering "not configured" in that
1834    /// gap makes scope admission refuse with the terminal "will never sync"
1835    /// instead of the retryable "has not synced yet".
1836    configured_ids: Arc<Mutex<HashSet<String>>>,
1837    spawn_events: SpawnEventFeed,
1838    /// The current expected launch nonce for each reserved module_id. Set when the
1839    /// supervisor spawns the reserved module; checked when a HELLO claims that id. A
1840    /// non-reserved module never has an entry here and is never nonce-checked.
1841    /// Reserved module ids and the nonce that authorizes their next HELLO.
1842    /// `None` means RESERVED WITH NO LEGITIMATE HOLDER — a reserved module that
1843    /// has never been spawned (e.g. configured `enabled: false`) — and refuses
1844    /// every HELLO. Before this was expressible, a reserved-but-never-spawned id
1845    /// had NO entry and admitted anyone: the reservation protected the nonce
1846    /// holder, not the NAME (found live by CKCRED's canary probe registering
1847    /// against a reserved scratch id).
1848    reserved_nonces: Arc<Mutex<HashMap<String, Option<String>>>>,
1849    /// Module ids removed by an executed rescan and the unix-millisecond removal time.
1850    ///
1851    /// This is deliberately in-memory only: subc is state-free across daemon
1852    /// restarts, and the tombstone only explains the hours-after-removal window
1853    /// while this executing daemon is still alive. Do not persist it in a store.
1854    removal_tombstones: Arc<Mutex<HashMap<String, u64>>>,
1855    /// The current launch nonce for every supervised spawn. This is separate from
1856    /// reserved_nonces because consumer route.open attestation applies to all spawned
1857    /// modules, while HELLO id-squatting protection remains opt-in via `reserved`.
1858    spawn_nonces: Arc<Mutex<HashMap<String, String>>>,
1859    /// Reserved namespace prefixes mapped to the supervised owner module whose
1860    /// current spawn nonce authorizes HELLO claims below the prefix.
1861    ///
1862    /// Per §2.6 this is not a same-user security barrier: a same-user process can
1863    /// read the key file and launch nonce env. Like exact reserved ids, it prevents
1864    /// accidental collisions and lower-trust processes from squatting protected
1865    /// namespaces.
1866    reserved_prefix_owners: Arc<Mutex<HashMap<String, String>>>,
1867    /// Blue/green swaps in progress, by module id. An entry exists from just
1868    /// before the candidate process is spawned until the swap has failed, or
1869    /// has cut over and the old process is gone. While it exists, HELLO for the
1870    /// id is gated on the swap token (see [`Self::swap_hello_admission`]) and
1871    /// consumer attestation accepts both processes' nonces.
1872    swaps: Arc<Mutex<HashMap<String, OpenSwap>>>,
1873    /// Told when a swap promotes its candidate; see [`SwapPromotionObserver`].
1874    promotion_observer: PromotionObserverSlot,
1875    /// Serializes module-set reconciliation with operator lifecycle commands. Without
1876    /// this daemon-wide ordering, a rescan could retire or update a module while a
1877    /// concurrent reload still held its old handle and launch specification.
1878    operation_lock: Arc<AsyncMutex<()>>,
1879}
1880
1881/// Told when a swap has promoted its candidate to be the module's active
1882/// registration.
1883///
1884/// An ordinary HELLO runs the control plane's registration side effects (the
1885/// capability cache, the deny census, the requirement recompute) as it
1886/// registers. A swap candidate's HELLO does not, because it is not routable;
1887/// promotion is when those must run instead, and promotion happens in the
1888/// supervisor, which has no other way into the control handler.
1889pub(crate) trait SwapPromotionObserver: Send + Sync {
1890    fn swap_promoted(&self, registration: &crate::registry::ModuleRegistration);
1891}
1892
1893/// The installed [`SwapPromotionObserver`], held weakly: the observer (the
1894/// control handler) owns this handle, so a strong reference back would be a
1895/// cycle that keeps both alive.
1896#[derive(Clone, Default)]
1897struct PromotionObserverSlot(Arc<Mutex<Option<std::sync::Weak<dyn SwapPromotionObserver>>>>);
1898
1899impl fmt::Debug for PromotionObserverSlot {
1900    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1901        f.write_str("PromotionObserverSlot")
1902    }
1903}
1904
1905/// The nonces of one open swap.
1906#[derive(Debug, Clone)]
1907struct OpenSwap {
1908    /// The launch nonce minted for the candidate process. It is the swap
1909    /// token: the only thing that admits a HELLO into the candidate slot.
1910    candidate_nonce: String,
1911    /// The incumbent's launch nonce, captured when the swap opened. It is kept
1912    /// here because cutover moves the module's recorded spawn nonce to the
1913    /// candidate while the incumbent is still draining and its consumers are
1914    /// still attesting with this one.
1915    incumbent_nonce: Option<String>,
1916    /// Set once a HELLO has been admitted with the swap token, so the token
1917    /// admits one registration and cannot be replayed after cutover empties
1918    /// the candidate slot.
1919    candidate_admitted: bool,
1920}
1921
1922/// What the swap gate says about a HELLO. See
1923/// [`SupervisorHandle::swap_hello_admission`].
1924#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1925pub(crate) enum SwapHelloAdmission {
1926    /// No swap is open for the id (or the HELLO carries the incumbent's own
1927    /// nonce); the ordinary gates decide.
1928    NotSwapping,
1929    /// The HELLO carries the swap token: register it into the candidate slot.
1930    Candidate,
1931    /// A swap is open and the HELLO carries a nonce the supervisor did not
1932    /// mint for this id, no nonce, or a token already used.
1933    Refused,
1934}
1935
1936#[derive(Debug, Clone, PartialEq, Eq)]
1937pub(crate) enum ReservedHelloRejection {
1938    Exact {
1939        module_id: String,
1940    },
1941    Prefix {
1942        prefix: String,
1943        owner_module_id: String,
1944    },
1945}
1946
1947impl SupervisorHandle {
1948    pub fn new() -> Self {
1949        Self::default()
1950    }
1951
1952    pub(crate) fn spawn_snapshot(&self) -> SpawnSnapshot {
1953        self.spawn_events.snapshot()
1954    }
1955
1956    pub(crate) fn subscribe_spawns(
1957        &self,
1958        connection_id: ConnectionId,
1959        corr: u64,
1960        version: u8,
1961        since: Option<SpawnCursor>,
1962        sink: FrameSink,
1963    ) -> Result<(), SpawnSubscribeRefusal> {
1964        self.spawn_events
1965            .subscribe(connection_id, corr, version, since, sink)
1966    }
1967
1968    pub(crate) fn cancel_spawn_subscription(&self, connection_id: ConnectionId, corr: u64) -> bool {
1969        self.spawn_events.cancel(connection_id, corr)
1970    }
1971
1972    pub(crate) fn remove_spawn_subscribers(&self, connection_id: ConnectionId) {
1973        self.spawn_events.remove_connection(connection_id);
1974    }
1975
1976    #[cfg(any(test, feature = "test-support"))]
1977    pub fn set_spawn_event_capacity_for_test(&self, capacity: usize) {
1978        assert!(capacity > 0, "spawn event capacity must be non-zero");
1979        self.spawn_events.set_capacity(capacity);
1980    }
1981
1982    #[cfg(any(test, feature = "test-support"))]
1983    pub fn spawn_subscriber_count_for_test(&self) -> usize {
1984        self.spawn_events.subscriber_count()
1985    }
1986
1987    /// Record the launch nonce from a supervised spawn, replacing any prior nonce so
1988    /// a respawn invalidates stale consumer identities.
1989    pub fn set_spawn_nonce(&self, module_id: &str, nonce: String) {
1990        self.spawn_nonces
1991            .lock()
1992            .unwrap_or_else(|poisoned| poisoned.into_inner())
1993            .insert(module_id.to_string(), nonce);
1994    }
1995
1996    /// Record the launch nonce expected from the next HELLO for a reserved module,
1997    /// replacing any prior nonce (a respawn invalidates the previous one).
1998    pub fn set_reserved_nonce(&self, module_id: &str, nonce: String) {
1999        self.reserved_nonces
2000            .lock()
2001            .unwrap_or_else(|poisoned| poisoned.into_inner())
2002            .insert(module_id.to_string(), Some(nonce));
2003    }
2004
2005    /// Record namespace prefixes owned by a supervised module.
2006    pub fn set_reserved_prefixes(&self, owner_module_id: &str, prefixes: &[String]) {
2007        let mut owners = self
2008            .reserved_prefix_owners
2009            .lock()
2010            .unwrap_or_else(|poisoned| poisoned.into_inner());
2011        owners.retain(|_, owner| owner != owner_module_id);
2012        for prefix in prefixes {
2013            owners.insert(prefix.clone(), owner_module_id.to_string());
2014        }
2015    }
2016
2017    /// The launch nonce most recently minted for a module's spawn, if any.
2018    #[cfg(test)]
2019    pub(crate) fn spawn_nonce(&self, module_id: &str) -> Option<String> {
2020        self.spawn_nonces
2021            .lock()
2022            .unwrap_or_else(|poisoned| poisoned.into_inner())
2023            .get(module_id)
2024            .cloned()
2025    }
2026
2027    fn apply_identity_configuration(&self, spec: &ModuleSpec) {
2028        self.set_reserved_prefixes(&spec.module_id, &spec.reserved_prefixes);
2029        let spawn_nonce = self
2030            .spawn_nonces
2031            .lock()
2032            .unwrap_or_else(|poisoned| poisoned.into_inner())
2033            .get(&spec.module_id)
2034            .cloned();
2035        let mut reserved_nonces = self
2036            .reserved_nonces
2037            .lock()
2038            .unwrap_or_else(|poisoned| poisoned.into_inner());
2039        if spec.reserved {
2040            // `None` (no spawn nonce minted) is INSERTED, not skipped: a
2041            // reserved name whose module has never spawned has no legitimate
2042            // holder, and the entry's absence is what used to leave the name
2043            // open to the first claimant.
2044            reserved_nonces.insert(spec.module_id.clone(), spawn_nonce);
2045        }
2046        drop(reserved_nonces);
2047        // A later unreserved declaration must not silently unreserve an id that
2048        // was retained after its reserved configuration was removed. The explicit
2049        // release ceremony is the only operation that retires that gate.
2050        self.removal_tombstones
2051            .lock()
2052            .unwrap_or_else(|poisoned| poisoned.into_inner())
2053            .remove(&spec.module_id);
2054    }
2055
2056    /// Whether a HELLO claiming `module_id` is authorized. An exact reserved id is
2057    /// authorized only by its expected nonce; otherwise a matching reserved prefix
2058    /// is authorized by the owner module's current spawn nonce. Non-reserved ids
2059    /// with no matching prefix are always authorized.
2060    pub fn reserved_hello_authorized(&self, module_id: &str, presented: Option<&str>) -> bool {
2061        self.reserved_hello_rejection(module_id, presented)
2062            .is_none()
2063    }
2064
2065    pub(crate) fn reserved_hello_rejection(
2066        &self,
2067        module_id: &str,
2068        presented: Option<&str>,
2069    ) -> Option<ReservedHelloRejection> {
2070        let nonces = self
2071            .reserved_nonces
2072            .lock()
2073            .unwrap_or_else(|poisoned| poisoned.into_inner());
2074        if let Some(expected) = nonces.get(module_id) {
2075            // `None` = reserved with no legitimate holder: refuse every
2076            // presentation, because no process can hold a nonce that was never
2077            // minted. Only a real minted nonce admits, in constant time.
2078            let authorized = match expected {
2079                Some(expected) => {
2080                    presented.is_some_and(|p| constant_time_eq(expected.as_bytes(), p.as_bytes()))
2081                }
2082                None => false,
2083            };
2084            if authorized {
2085                return None;
2086            }
2087            return Some(ReservedHelloRejection::Exact {
2088                module_id: module_id.to_string(),
2089            });
2090        }
2091        drop(nonces);
2092
2093        let matched_prefix = self
2094            .reserved_prefix_owners
2095            .lock()
2096            .unwrap_or_else(|poisoned| poisoned.into_inner())
2097            .iter()
2098            .filter(|(prefix, _)| module_id.starts_with(prefix.as_str()))
2099            .max_by_key(|(prefix, _)| prefix.len())
2100            .map(|(prefix, owner)| (prefix.clone(), owner.clone()));
2101        let (prefix, owner_module_id) = matched_prefix?;
2102
2103        let authorized = presented.is_some_and(|presented| {
2104            self.spawn_nonces
2105                .lock()
2106                .unwrap_or_else(|poisoned| poisoned.into_inner())
2107                .get(&owner_module_id)
2108                .is_some_and(|expected| constant_time_eq(expected.as_bytes(), presented.as_bytes()))
2109                // While the owner is being swapped, children started by
2110                // either of its two processes hold that process's nonce.
2111                || self.swap_nonce_matches(&owner_module_id, presented)
2112        });
2113        if authorized {
2114            None
2115        } else {
2116            Some(ReservedHelloRejection::Prefix {
2117                prefix,
2118                owner_module_id,
2119            })
2120        }
2121    }
2122
2123    /// Whether a consumer connection proved it came from a daemon-spawned module.
2124    ///
2125    /// Absence of an expected spawn nonce is a hard failure: consumer_identity is
2126    /// accepted only for module ids the supervisor has spawned.
2127    pub fn spawned_consumer_authorized(&self, module_id: &str, presented: &str) -> bool {
2128        if presented.is_empty() {
2129            return false;
2130        }
2131        let nonces = self
2132            .spawn_nonces
2133            .lock()
2134            .unwrap_or_else(|poisoned| poisoned.into_inner());
2135        let current = nonces
2136            .get(module_id)
2137            .is_some_and(|expected| constant_time_eq(expected.as_bytes(), presented.as_bytes()));
2138        drop(nonces);
2139        // During a swap two processes of the module are alive, and a consumer
2140        // started by either one presents that process's nonce. Accepting only
2141        // the recorded one would fail the incumbent's consumers for the whole
2142        // overlap once cutover moves the record to the candidate.
2143        current || self.swap_nonce_matches(module_id, presented)
2144    }
2145
2146    /// Whether `presented` is either nonce of an open swap for `module_id`.
2147    fn swap_nonce_matches(&self, module_id: &str, presented: &str) -> bool {
2148        let swaps = self
2149            .swaps
2150            .lock()
2151            .unwrap_or_else(|poisoned| poisoned.into_inner());
2152        swaps.get(module_id).is_some_and(|swap| {
2153            constant_time_eq(swap.candidate_nonce.as_bytes(), presented.as_bytes())
2154                || swap.incumbent_nonce.as_deref().is_some_and(|incumbent| {
2155                    constant_time_eq(incumbent.as_bytes(), presented.as_bytes())
2156                })
2157        })
2158    }
2159
2160    /// Open a swap for `module_id` with the candidate's freshly minted nonce.
2161    /// Called before the candidate process exists.
2162    pub(crate) fn open_swap(&self, module_id: &str, candidate_nonce: String) {
2163        let incumbent_nonce = self
2164            .spawn_nonces
2165            .lock()
2166            .unwrap_or_else(|poisoned| poisoned.into_inner())
2167            .get(module_id)
2168            .cloned();
2169        self.swaps
2170            .lock()
2171            .unwrap_or_else(|poisoned| poisoned.into_inner())
2172            .insert(
2173                module_id.to_string(),
2174                OpenSwap {
2175                    candidate_nonce,
2176                    incumbent_nonce,
2177                    candidate_admitted: false,
2178                },
2179            );
2180    }
2181
2182    /// Close the swap for `module_id`, releasing whichever nonce is no longer
2183    /// the module's recorded one.
2184    pub(crate) fn close_swap(&self, module_id: &str) {
2185        self.swaps
2186            .lock()
2187            .unwrap_or_else(|poisoned| poisoned.into_inner())
2188            .remove(module_id);
2189    }
2190
2191    /// Install the observer told about swap promotions, replacing any earlier
2192    /// one.
2193    pub(crate) fn set_swap_promotion_observer(
2194        &self,
2195        observer: std::sync::Weak<dyn SwapPromotionObserver>,
2196    ) {
2197        *self
2198            .promotion_observer
2199            .0
2200            .lock()
2201            .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(observer);
2202    }
2203
2204    /// Tell the installed observer, if it is still alive, that a swap promoted
2205    /// `registration`.
2206    fn notify_swap_promoted(&self, registration: &crate::registry::ModuleRegistration) {
2207        let observer = self
2208            .promotion_observer
2209            .0
2210            .lock()
2211            .unwrap_or_else(|poisoned| poisoned.into_inner())
2212            .as_ref()
2213            .and_then(std::sync::Weak::upgrade);
2214        if let Some(observer) = observer {
2215            observer.swap_promoted(registration);
2216        }
2217    }
2218
2219    /// Whether a swap is open for `module_id`.
2220    pub(crate) fn swap_open(&self, module_id: &str) -> bool {
2221        self.swaps
2222            .lock()
2223            .unwrap_or_else(|poisoned| poisoned.into_inner())
2224            .contains_key(module_id)
2225    }
2226
2227    /// Make the candidate's nonce the module's recorded spawn nonce, as a plain
2228    /// respawn would, once cutover has made the candidate the module's process.
2229    /// The swap stays open so the incumbent's nonce keeps attesting until the
2230    /// incumbent has drained and exited.
2231    fn promote_swap_nonce(&self, module_id: &str, reserved: bool) {
2232        let candidate_nonce = self
2233            .swaps
2234            .lock()
2235            .unwrap_or_else(|poisoned| poisoned.into_inner())
2236            .get(module_id)
2237            .map(|swap| swap.candidate_nonce.clone());
2238        let Some(nonce) = candidate_nonce else {
2239            return;
2240        };
2241        self.set_spawn_nonce(module_id, nonce.clone());
2242        if reserved {
2243            self.set_reserved_nonce(module_id, nonce);
2244        }
2245    }
2246
2247    /// The swap gate for a HELLO claiming `module_id`.
2248    ///
2249    /// This runs BEFORE the reserved-module gate. A reserved module's candidate
2250    /// presents the candidate nonce, which the reserved gate (holding the
2251    /// incumbent's nonce) would refuse as `reserved_module` before swap
2252    /// admission was ever reached. And it applies to unreserved ids too: for an
2253    /// unreserved id the only thing that ever stopped a second process claiming
2254    /// a live id was the `duplicate_module_id` refusal, which is exactly the
2255    /// refusal a swap lifts for its candidate.
2256    ///
2257    /// The incumbent's own nonce falls through to the ordinary gates, which
2258    /// treat it as they always have (a live incumbent is refused as a
2259    /// duplicate). Anything else while a swap is open is refused, including an
2260    /// absent nonce.
2261    pub(crate) fn swap_hello_admission(
2262        &self,
2263        module_id: &str,
2264        presented: Option<&str>,
2265    ) -> SwapHelloAdmission {
2266        let swaps = self
2267            .swaps
2268            .lock()
2269            .unwrap_or_else(|poisoned| poisoned.into_inner());
2270        let Some(swap) = swaps.get(module_id) else {
2271            return SwapHelloAdmission::NotSwapping;
2272        };
2273        let Some(presented) = presented else {
2274            return SwapHelloAdmission::Refused;
2275        };
2276        if constant_time_eq(swap.candidate_nonce.as_bytes(), presented.as_bytes()) {
2277            return if swap.candidate_admitted {
2278                SwapHelloAdmission::Refused
2279            } else {
2280                SwapHelloAdmission::Candidate
2281            };
2282        }
2283        if swap
2284            .incumbent_nonce
2285            .as_deref()
2286            .is_some_and(|incumbent| constant_time_eq(incumbent.as_bytes(), presented.as_bytes()))
2287        {
2288            return SwapHelloAdmission::NotSwapping;
2289        }
2290        SwapHelloAdmission::Refused
2291    }
2292
2293    /// Record that the swap token has registered a candidate, so it admits no
2294    /// second HELLO.
2295    pub(crate) fn mark_swap_candidate_admitted(&self, module_id: &str) {
2296        if let Some(swap) = self
2297            .swaps
2298            .lock()
2299            .unwrap_or_else(|poisoned| poisoned.into_inner())
2300            .get_mut(module_id)
2301        {
2302            swap.candidate_admitted = true;
2303        }
2304    }
2305
2306    /// Test/support lookup for the current launch nonce of a supervised spawn.
2307    pub fn spawn_launch_nonce_for(&self, module_id: &str) -> Option<String> {
2308        self.spawn_nonces
2309            .lock()
2310            .unwrap_or_else(|poisoned| poisoned.into_inner())
2311            .get(module_id)
2312            .cloned()
2313    }
2314
2315    /// Test/support lookup for the HELLO-gating nonce of a reserved module.
2316    pub fn reserved_launch_nonce_for(&self, module_id: &str) -> Option<String> {
2317        self.reserved_nonces
2318            .lock()
2319            .unwrap_or_else(|poisoned| poisoned.into_inner())
2320            .get(module_id)
2321            .cloned()
2322            .flatten()
2323    }
2324
2325    pub fn insert(&self, module: SupervisedModule) -> Option<SupervisedModule> {
2326        // Normally already marked before the process was spawned; marking here
2327        // too keeps `configured_ids` a superset of the roster for any caller
2328        // that inserts a module directly.
2329        self.mark_configured(module.module_id());
2330        let mut modules = self
2331            .modules
2332            .lock()
2333            .unwrap_or_else(|poisoned| poisoned.into_inner());
2334        modules.insert(module.module_id().to_string(), module)
2335    }
2336
2337    /// Record that the supervisor has taken on `module_id`. Called before the
2338    /// module's first process is spawned, so that by the time that process can
2339    /// register, [`Self::is_configured`] already answers true.
2340    fn mark_configured(&self, module_id: &str) {
2341        self.configured_ids
2342            .lock()
2343            .unwrap_or_else(|poisoned| poisoned.into_inner())
2344            .insert(module_id.to_string());
2345    }
2346
2347    /// Undo [`Self::mark_configured`] for a module whose first spawn failed
2348    /// before it was ever put on the roster. A module already on the roster
2349    /// keeps its mark: only [`Self::retire`] takes a rostered module off.
2350    fn unmark_configured_unless_rostered(&self, module_id: &str) {
2351        let modules = self
2352            .modules
2353            .lock()
2354            .unwrap_or_else(|poisoned| poisoned.into_inner());
2355        if !modules.contains_key(module_id) {
2356            self.configured_ids
2357                .lock()
2358                .unwrap_or_else(|poisoned| poisoned.into_inner())
2359                .remove(module_id);
2360        }
2361    }
2362
2363    /// Whether `module_id` is a module this daemon supervises: on the roster,
2364    /// or about to be (its process is being spawned right now).
2365    ///
2366    /// This, not `get(..).is_some()`, is what "the owner is configured" means
2367    /// for scopes: a supervised module's process can register and sync before
2368    /// [`Self::get`] can return it, and in that window it is still a module
2369    /// that will sync, not one that never will.
2370    pub(crate) fn is_configured(&self, module_id: &str) -> bool {
2371        self.configured_ids
2372            .lock()
2373            .unwrap_or_else(|poisoned| poisoned.into_inner())
2374            .contains(module_id)
2375    }
2376
2377    pub fn get(&self, module_id: &str) -> Option<SupervisedModule> {
2378        let modules = self
2379            .modules
2380            .lock()
2381            .unwrap_or_else(|poisoned| poisoned.into_inner());
2382        modules.get(module_id).cloned()
2383    }
2384
2385    pub(crate) fn record_late_health_answer(
2386        &self,
2387        module_id: &str,
2388        latency_ms: u64,
2389    ) -> Result<bool, SuperviseError> {
2390        let Some(module) = self.get(module_id) else {
2391            return Ok(false);
2392        };
2393        update_snapshot(&module.inner.snapshot, Some(module_id), |state| {
2394            state.health.late_answer_count = state.health.late_answer_count.saturating_add(1);
2395            state.health.last_late_answer_latency_ms = Some(latency_ms);
2396            // A late answer is an answer: the module served the probe, just past
2397            // the deadline. Leaving the miss streak in place while logging
2398            // "proves the module is alive" is how a CPU-starved module that
2399            // answers every probe a few seconds late still marches to the
2400            // threshold and gets killed — the exact kill class `NoAnswer` is
2401            // excluded from `is_proof_of_death` to prevent. Slow-but-answering
2402            // is degradation, and degradation reports; it does not restart.
2403            state.health.consecutive_failures = 0;
2404        })?;
2405        Ok(true)
2406    }
2407
2408    /// Arm the one-shot marker for the module process that this caller
2409    /// deliberately initiated severance against. Generic connection teardown
2410    /// must not call this:
2411    /// a surviving process would otherwise retain an exemption for a later
2412    /// genuine crash.
2413    pub fn record_deliberate_severance(&self, module_id: &str) -> Result<bool, SuperviseError> {
2414        let Some(module) = self.get(module_id) else {
2415            return Ok(false);
2416        };
2417        let snapshot = lock_snapshot(&module.inner.snapshot)?;
2418        let Some((pid, start_time)) = snapshot.pid.zip(snapshot.process_start_time) else {
2419            return Ok(false);
2420        };
2421        drop(snapshot);
2422        module.record_deliberate_severance(ProcessIdentity { pid, start_time })
2423    }
2424
2425    pub fn list(&self) -> Vec<SupervisedModule> {
2426        let modules = self
2427            .modules
2428            .lock()
2429            .unwrap_or_else(|poisoned| poisoned.into_inner());
2430        let mut modules = modules.values().cloned().collect::<Vec<_>>();
2431        modules.sort_by(|left, right| left.module_id().cmp(right.module_id()));
2432        modules
2433    }
2434
2435    pub(crate) fn retire(&self, module_id: &str) -> Option<SupervisedModule> {
2436        self.activity_images
2437            .lock()
2438            .unwrap_or_else(|p| p.into_inner())
2439            .remove(module_id);
2440        self.spawn_nonces
2441            .lock()
2442            .unwrap_or_else(|poisoned| poisoned.into_inner())
2443            .remove(module_id);
2444        self.close_swap(module_id);
2445        let mut reserved_nonces = self
2446            .reserved_nonces
2447            .lock()
2448            .unwrap_or_else(|poisoned| poisoned.into_inner());
2449        if reserved_nonces.contains_key(module_id) {
2450            // The old nonce must die with the removed process, but the exact-id
2451            // gate remains until an operator explicitly releases it.
2452            reserved_nonces.insert(module_id.to_string(), None);
2453        }
2454        drop(reserved_nonces);
2455        self.reserved_prefix_owners
2456            .lock()
2457            .unwrap_or_else(|poisoned| poisoned.into_inner())
2458            .retain(|_, owner| owner != module_id);
2459        let removed = self
2460            .modules
2461            .lock()
2462            .unwrap_or_else(|poisoned| poisoned.into_inner())
2463            .remove(module_id);
2464        self.configured_ids
2465            .lock()
2466            .unwrap_or_else(|poisoned| poisoned.into_inner())
2467            .remove(module_id);
2468        removed
2469    }
2470
2471    /// Remember a module removed by a non-preview rescan so route.open can
2472    /// distinguish that intentional removal from an unknown id.
2473    pub(crate) fn record_rescan_removal(&self, module_id: &str) {
2474        self.removal_tombstones
2475            .lock()
2476            .unwrap_or_else(|poisoned| poisoned.into_inner())
2477            .insert(module_id.to_string(), unix_ms_now());
2478    }
2479
2480    /// Return how long ago a rescan removed this module in milliseconds.
2481    pub(crate) fn removal_tombstone_age_ms(&self, module_id: &str) -> Option<u64> {
2482        self.removal_tombstones
2483            .lock()
2484            .unwrap_or_else(|poisoned| poisoned.into_inner())
2485            .get(module_id)
2486            .copied()
2487            .map(|removed_at_ms| unix_ms_now().saturating_sub(removed_at_ms))
2488    }
2489
2490    /// Retire a reserved-id gate only after its module has left supervision.
2491    ///
2492    /// A retained gate has no live nonce (`None`), so releasing any other entry
2493    /// would weaken a currently configured or otherwise active reservation.
2494    pub(crate) fn release_retained_reserved_gate(&self, module_id: &str) -> bool {
2495        if self.get(module_id).is_some() {
2496            return false;
2497        }
2498        let mut reserved_nonces = self
2499            .reserved_nonces
2500            .lock()
2501            .unwrap_or_else(|poisoned| poisoned.into_inner());
2502        if !matches!(reserved_nonces.get(module_id), Some(None)) {
2503            return false;
2504        }
2505        reserved_nonces.remove(module_id);
2506        true
2507    }
2508
2509    pub(crate) fn operation_lock(&self) -> Arc<AsyncMutex<()>> {
2510        Arc::clone(&self.operation_lock)
2511    }
2512}
2513
2514/// Process supervisor for subc-owned singleton modules.
2515#[derive(Debug, Clone)]
2516pub struct Supervisor {
2517    registry: Arc<Registry>,
2518    restart_policy: RestartPolicy,
2519    drain_timeout: Duration,
2520    connection_file_path: Option<PathBuf>,
2521    capture_logs_dir: Option<PathBuf>,
2522    forwarding: Option<Arc<ForwardingTable>>,
2523    process_liveness: Arc<SupervisorProcessLiveness>,
2524    supervisor_handle: Option<SupervisorHandle>,
2525    health: HealthConfig,
2526    daemon_start_clock: crate::clock::StartClock,
2527    terminal_journal: Option<Arc<crate::terminal_journal::TerminalJournal>>,
2528    spawn_events: SpawnEventFeed,
2529    provenance_probe: ExecutableIdentityProbe,
2530    /// Every process spawned through this supervisor (and its clones) and not
2531    /// yet reaped, so daemon shutdown can end them.
2532    child_roster: ChildRoster,
2533    #[cfg(target_os = "linux")]
2534    cgroup_placement: Option<subc_cgroup::Placement>,
2535    #[cfg(test)]
2536    test_after_first_spawn: AfterFirstSpawnHook,
2537}
2538
2539/// A roster entry whose monitor and first process have not started yet.
2540/// Keeping the command receiver here lets discovery serve status immediately
2541/// while start/stop requests wait until bootstrap has published the connection file.
2542pub(crate) struct RegisteredConfiguredModule {
2543    spec: ModuleSpec,
2544    runtime: SupervisorRuntimeConfig,
2545    module: SupervisedModule,
2546    commands: mpsc::Receiver<SupervisorCommand>,
2547}
2548
2549/// Test-only hook run on the path that takes on a new module, right after its
2550/// first `spawn_child` returns (the process exists and could already be
2551/// registering) and before that process is handed to the module's supervise
2552/// loop and put on the roster. Lets a test observe what a fast child would see
2553/// in that window without racing a real one.
2554#[cfg(test)]
2555#[derive(Clone, Default)]
2556struct AfterFirstSpawnHook(Option<AfterFirstSpawnFn>);
2557
2558#[cfg(test)]
2559type AfterFirstSpawnFn = Arc<dyn Fn(&str) + Send + Sync>;
2560
2561#[cfg(test)]
2562impl fmt::Debug for AfterFirstSpawnHook {
2563    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2564        f.write_str("AfterFirstSpawnHook")
2565    }
2566}
2567
2568#[cfg(test)]
2569impl AfterFirstSpawnHook {
2570    fn run(&self, module_id: &str) {
2571        if let Some(hook) = &self.0 {
2572            hook(module_id);
2573        }
2574    }
2575}
2576
2577impl Supervisor {
2578    #[cfg(test)]
2579    pub(crate) fn new_for_test(registry: Arc<Registry>, policy: RestartPolicy) -> Self {
2580        let supervisor = Self::new(registry, policy);
2581        #[cfg(target_os = "macos")]
2582        let supervisor = supervisor.with_privacy_trampoline(test_privacy_trampoline());
2583        supervisor
2584    }
2585    /// Verify the trampoline once when configured. Missing private OS support
2586    /// refuses every macOS launch by name but does not stop the daemon's control
2587    /// server. Embedders must explicitly provide a binary with the subc-os hidden
2588    /// entry point; the library must not exec an arbitrary hosting program.
2589    pub fn with_privacy_trampoline(self, path: impl Into<PathBuf>) -> Self {
2590        let path = path.into();
2591        #[cfg(target_os = "macos")]
2592        {
2593            let result = probe_privacy_trampoline(&path).map(|()| path);
2594            if let Err(cause) = &result {
2595                error!(%cause, "macOS privacy identity unavailable; supervised launches will refuse");
2596            }
2597            self.child_roster.set_privacy_trampoline(result);
2598        }
2599        #[cfg(not(target_os = "macos"))]
2600        let _ = path;
2601        self
2602    }
2603    /// The first step of an announced daemon shutdown, before the notice and
2604    /// before any connection is closed.
2605    ///
2606    /// Sets the daemon-shutdown flag first: from here on no module is
2607    /// respawned (crash restart, operator restart, or swap), and every child
2608    /// exit is recorded as `daemon_shutdown` rather than as a crash, whether
2609    /// the module exits on the EOF this shutdown gives it or is signalled by a
2610    /// service manager that kills the whole cgroup. Then writes the journal's
2611    /// shutdown marker, which records the instant and closes this daemon
2612    /// incarnation's stretch of the journal.
2613    #[cfg(unix)]
2614    pub(crate) fn begin_daemon_shutdown(&self) {
2615        self.child_roster.close();
2616        if let Some(journal) = &self.terminal_journal {
2617            journal.stamp_shutdown();
2618        }
2619    }
2620
2621    /// Announce a cut while established connections can still carry replies.
2622    /// These budgets promise notice and a bounded wait, not child completion;
2623    /// they are local policy, not an estimate of launchd's unknown kill ceiling.
2624    #[cfg(unix)]
2625    pub(crate) async fn drain_for_daemon_shutdown(&self) -> Result<(), SuperviseError> {
2626        const NOTICE_BUDGET: Duration = Duration::from_millis(500);
2627        const DRAIN_BUDGET: Duration = Duration::from_secs(2);
2628        let Some(forwarding) = &self.forwarding else {
2629            return Ok(());
2630        };
2631        let modules = forwarding
2632            .begin_daemon_drain()
2633            .map_err(SuperviseError::Forwarding)?;
2634        let deadline_ms =
2635            unix_ms_now().saturating_add((NOTICE_BUDGET + DRAIN_BUDGET).as_millis() as u64);
2636        let mut notices = tokio::task::JoinSet::new();
2637        let mut drains = Vec::new();
2638        for (module_id, endpoint) in modules {
2639            let Some(target) = forwarding
2640                .begin_endpoint_drain(endpoint, RouteCloseReason::Restart)
2641                .map_err(SuperviseError::Forwarding)?
2642            else {
2643                continue;
2644            };
2645            let routes = forwarding
2646                .endpoint_routes(target.endpoint)
2647                .map_err(SuperviseError::Forwarding)?;
2648            // Restart allows deployed consumers to reopen after the new daemon
2649            // appears. The wire reason stays `restart`; what tells a daemon cut
2650            // apart from a module restart afterwards is the terminal record
2651            // itself, whose disposition is `daemon_shutdown` for every exit
2652            // observed once `begin_daemon_shutdown` has run.
2653            let command = serde_json::to_vec(&ModuleControlCommand::Draining {
2654                reason: RouteCloseReason::Restart,
2655                deadline_ms,
2656            })
2657            .expect("module draining serializes");
2658            let mut recipients = vec![(target.sink.clone(), target.negotiated_ver, command)];
2659            let mut clients: Vec<(crate::forwarding::GoodbyeTarget, Vec<u16>)> = Vec::new();
2660            for route in routes {
2661                let client = route.goodbye_target;
2662                if let Some((_, channels)) = clients
2663                    .iter_mut()
2664                    .find(|(existing, _)| existing.connection_id == client.connection_id)
2665                {
2666                    channels.push(client.channel);
2667                } else {
2668                    let channel = client.channel;
2669                    clients.push((client, vec![channel]));
2670                }
2671            }
2672            for (client, mut channels) in clients {
2673                channels.sort_unstable();
2674                channels.dedup();
2675                let closing = serde_json::to_vec(&ClientControlPush::RouteClosing {
2676                    module_id: module_id.clone(),
2677                    channels,
2678                    reason: RouteCloseReason::Restart,
2679                })
2680                .expect("route closing serializes");
2681                recipients.push((client.sink, client.negotiated_ver, closing));
2682            }
2683            for (sink, version, body) in recipients {
2684                notices.spawn(async move {
2685                    let frame = Frame::build_with_version(
2686                        version,
2687                        FrameType::Push,
2688                        control_flags(),
2689                        0,
2690                        0,
2691                        0,
2692                        body,
2693                    )
2694                    .expect("bounded lifecycle notice frame builds");
2695                    sink.send_flushed(frame).await
2696                });
2697            }
2698            let gauges = declared_busy_gauges(&self.registry, &module_id)?;
2699            drains.push((module_id, target.endpoint, gauges));
2700        }
2701        // A quiet forwarding table is not proof that queued notices reached the
2702        // socket. Wait for writer flush acknowledgements before testing quiescence.
2703        let notice_deadline = Instant::now() + NOTICE_BUDGET;
2704        while let Ok(Some(result)) = timeout_at(notice_deadline, notices.join_next()).await {
2705            if !matches!(result, Ok(Ok(()))) {
2706                warn!(?result, "daemon shutdown notice delivery failed");
2707            }
2708        }
2709        notices.abort_all();
2710        let deadline = Instant::now() + DRAIN_BUDGET;
2711        let mut waits = tokio::task::JoinSet::new();
2712        for (module_id, endpoint, gauges) in drains {
2713            let forwarding = Arc::clone(forwarding);
2714            let mut runtime = self.runtime_config();
2715            runtime.health.cadence = Duration::from_millis(100);
2716            waits.spawn(async move {
2717                let wait_result = wait_for_forwarding_quiescence(
2718                    &forwarding,
2719                    &module_id,
2720                    &runtime,
2721                    endpoint,
2722                    deadline,
2723                    &gauges,
2724                    DrainScope::Endpoint(endpoint),
2725                )
2726                .await;
2727                // Store the measurement before the task returns: quiescence
2728                // may be reached just as the deadline stops the join loop from
2729                // collecting results. Unfinished or failed waits report false.
2730                forwarding
2731                    .record_daemon_drain_result(
2732                        endpoint,
2733                        drained_after_quiescence_wait(&wait_result),
2734                    )
2735                    .map_err(SuperviseError::Forwarding)?;
2736                wait_result
2737            });
2738        }
2739        while let Ok(Some(result)) = timeout_at(deadline, waits.join_next()).await {
2740            if !matches!(result, Ok(Ok(true))) {
2741                warn!(?result, "daemon shutdown drain did not reach quiescence");
2742            }
2743        }
2744        Ok(())
2745    }
2746
2747    /// The last step of an announced daemon shutdown, after the notice and the
2748    /// drain: send every registered module a module GOODBYE, the same planned
2749    /// stop signal `ck module stop` gives, then close every connection so each
2750    /// subc module sees EOF and starts its own teardown, then end every
2751    /// supervised child that has not exited
2752    /// by its own deadline (its drain budget, capped). Modules lead their own
2753    /// process groups, so a
2754    /// service manager's group kill no longer reaches them; without this a
2755    /// child that does not stop on EOF (every `protocol: "none"` child, which
2756    /// has no connection) would outlive the daemon. Every wait is bounded (see
2757    /// `child_roster`), and `escalate` resolving (a second SIGTERM) cuts them.
2758    #[cfg(unix)]
2759    pub(crate) async fn end_children_for_daemon_shutdown(
2760        &self,
2761        already_escalated: bool,
2762        escalate: impl std::future::Future<Output = ()>,
2763    ) {
2764        tokio::pin!(escalate);
2765        let mut escalated = already_escalated;
2766        if let Some(forwarding) = &self.forwarding {
2767            let reason = CloseReason::new(
2768                "daemon_shutdown",
2769                "the daemon is exiting after its shutdown notice and drain",
2770            );
2771            if escalated {
2772                // The operator asked to stop waiting: queue the GOODBYEs but
2773                // do not wait for them to be written.
2774                send_module_goodbyes_for_daemon_shutdown(forwarding, &reason, false).await;
2775            } else {
2776                tokio::select! {
2777                    biased;
2778                    _ = escalate.as_mut() => {
2779                        info!("second SIGTERM: abandoning module GOODBYE delivery");
2780                        escalated = true;
2781                    }
2782                    _ = send_module_goodbyes_for_daemon_shutdown(forwarding, &reason, true) => {}
2783                }
2784            }
2785            let closed = forwarding.close_all_connections(&reason);
2786            debug!(closed, "closed established connections for daemon shutdown");
2787        }
2788        // If the second SIGTERM arrived during GOODBYE delivery, `escalate` has
2789        // already completed and must not be polled again; the child shutdown
2790        // wait is told it is escalated and gets a future that never fires.
2791        let escalated_here = escalated && !already_escalated;
2792        let remaining_escalate = async move {
2793            if escalated_here {
2794                std::future::pending::<()>().await;
2795            } else {
2796                escalate.await;
2797            }
2798        };
2799        crate::child_roster::end_children_for_daemon_shutdown(
2800            &self.child_roster,
2801            escalated,
2802            remaining_escalate,
2803        )
2804        .await;
2805    }
2806
2807    pub fn new(registry: Arc<Registry>, restart_policy: RestartPolicy) -> Self {
2808        Self {
2809            registry,
2810            restart_policy,
2811            drain_timeout: DEFAULT_DRAIN_TIMEOUT,
2812            connection_file_path: None,
2813            capture_logs_dir: None,
2814            forwarding: None,
2815            process_liveness: Arc::new(SupervisorProcessLiveness::default()),
2816            supervisor_handle: None,
2817            health: HealthConfig::default(),
2818            daemon_start_clock: crate::clock::StartClock::capture(),
2819            terminal_journal: None,
2820            spawn_events: SpawnEventFeed::default(),
2821            provenance_probe: ExecutableIdentityProbe::default(),
2822            child_roster: ChildRoster::default(),
2823            #[cfg(target_os = "linux")]
2824            cgroup_placement: None,
2825            #[cfg(test)]
2826            test_after_first_spawn: AfterFirstSpawnHook::default(),
2827        }
2828    }
2829
2830    pub fn with_drain_timeout(mut self, drain_timeout: Duration) -> Self {
2831        self.drain_timeout = drain_timeout;
2832        self
2833    }
2834
2835    pub fn with_process_liveness(
2836        mut self,
2837        process_liveness: Arc<SupervisorProcessLiveness>,
2838    ) -> Self {
2839        self.process_liveness = process_liveness;
2840        self
2841    }
2842
2843    pub fn with_connection_file_path(mut self, connection_file_path: impl Into<PathBuf>) -> Self {
2844        self.connection_file_path = Some(connection_file_path.into());
2845        self
2846    }
2847
2848    /// Enables daemon-owned capture files for supervised stdout and stderr.
2849    pub fn with_capture_logs_dir(mut self, logs_dir: impl Into<PathBuf>) -> Self {
2850        self.capture_logs_dir = Some(logs_dir.into());
2851        self
2852    }
2853
2854    /// Names this daemon lifetime in spawn events, independently of whether a
2855    /// terminal journal is configured.
2856    pub fn with_daemon_incarnation(self, daemon_incarnation: String) -> Self {
2857        // A millisecond start stamp can repeat after clock rollback or a rapid
2858        // restart. Use the connection file's random daemon_id instead: it already
2859        // identifies this daemon lifetime independently of the wall clock.
2860        self.spawn_events.configure_incarnation(daemon_incarnation);
2861        self
2862    }
2863
2864    /// Enables best-effort history shared by every supervised module. Without
2865    /// it, terminal history is kept only in each module's in-memory ring.
2866    pub fn with_terminal_journal(self, path: PathBuf, daemon_incarnation: String) -> Self {
2867        let mut this = self.with_daemon_incarnation(daemon_incarnation.clone());
2868        this.terminal_journal = Some(Arc::new(crate::terminal_journal::TerminalJournal::open(
2869            path,
2870            daemon_incarnation,
2871        )));
2872        this
2873    }
2874
2875    pub fn with_forwarding(mut self, forwarding: Arc<ForwardingTable>) -> Self {
2876        self.forwarding = Some(forwarding);
2877        self
2878    }
2879
2880    pub fn with_handle(mut self, supervisor_handle: SupervisorHandle) -> Self {
2881        self.spawn_events = supervisor_handle.spawn_events.clone();
2882        self.supervisor_handle = Some(supervisor_handle);
2883        self
2884    }
2885
2886    pub fn with_health_config(mut self, health: HealthConfig) -> Self {
2887        self.health = health;
2888        self
2889    }
2890
2891    /// Keeps a record of every live child at `path`, rewritten on each spawn and
2892    /// reap, for the orphan sweep a later daemon runs at boot. Without it no
2893    /// record is kept.
2894    pub fn with_live_children_record(self, path: impl Into<PathBuf>) -> Self {
2895        self.child_roster.record_to(path.into());
2896        self
2897    }
2898
2899    #[cfg(target_os = "linux")]
2900    pub fn with_cgroup_placement(
2901        mut self,
2902        cgroup_placement: Option<subc_cgroup::Placement>,
2903    ) -> Self {
2904        self.cgroup_placement = cgroup_placement;
2905        self
2906    }
2907
2908    /// Spawn `spec.program` and start monitoring it.
2909    ///
2910    /// The child is expected to parse `--subc <connection-file-path>`, read the
2911    /// TCP+key connection file, authenticate to the already-running listener, and
2912    /// register with channel-0 `HELLO` using `spec.module_id` as its manifest id.
2913    pub fn spawn(&self, spec: ModuleSpec) -> Result<SupervisedModule, SuperviseError> {
2914        validate_spec(&spec)?;
2915        self.establish_identity(&spec);
2916
2917        let runtime = self.runtime_config();
2918        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
2919        let activity_spawn = runtime.begin_activity_spawn(&spec.module_id);
2920        let spawned = spawn_child(
2921            &spec,
2922            runtime.connection_file_path.as_deref(),
2923            self.supervisor_handle.as_ref(),
2924            &runtime.stderr_ring,
2925            runtime.capture_logs_dir.as_deref(),
2926            &runtime.child_roster,
2927            #[cfg(target_os = "linux")]
2928            runtime.cgroup_placement.as_ref(),
2929        );
2930        #[cfg(test)]
2931        self.test_after_first_spawn.run(&spec.module_id);
2932        let child = match spawned {
2933            Ok(child) => child,
2934            Err(err) => {
2935                // Unlike the configured paths, a failed `spawn` leaves nothing
2936                // on the roster, so the module must not stay marked configured.
2937                self.abandon_unrostered(&spec.module_id);
2938                return Err(err);
2939            }
2940        };
2941        self.mark_first_process_running(&spec, &runtime, &snapshot, &child)?;
2942        if let Some(spawn) = activity_spawn {
2943            spawn.complete(&snapshot);
2944        }
2945
2946        Ok(self.supervised_module(spec, runtime, snapshot, Some(child)))
2947    }
2948
2949    /// Make `spec`'s module count as configured, with its identity gates
2950    /// (reserved id, reserved prefixes) in place, BEFORE any process of it
2951    /// exists.
2952    ///
2953    /// Every path that takes on a new module calls this before `spawn_child`.
2954    /// The order is the point: the child can connect, register, sync its
2955    /// scopes and ask about them as soon as it is spawned, and the module is
2956    /// only put on the roster after `spawn_child` returns. Were the mark set
2957    /// with the roster entry, a fast child would see its own owner reported
2958    /// as not configured, and a scoped `route.open` in that window would be
2959    /// refused as terminal `scope_not_live` ("will never sync") instead of
2960    /// retryable `scope_not_synced`.
2961    fn establish_identity(&self, spec: &ModuleSpec) {
2962        if let Some(supervisor_handle) = &self.supervisor_handle {
2963            supervisor_handle.apply_identity_configuration(spec);
2964            supervisor_handle.mark_configured(&spec.module_id);
2965        }
2966    }
2967
2968    /// Take back [`Self::establish_identity`]'s configured mark when the
2969    /// module will not be put on the roster after all.
2970    fn abandon_unrostered(&self, module_id: &str) {
2971        if let Some(supervisor_handle) = &self.supervisor_handle {
2972            supervisor_handle.unmark_configured_unless_rostered(module_id);
2973        }
2974    }
2975
2976    /// Record a freshly spawned first process as running. On failure the
2977    /// module never reaches the roster, so its configured mark is taken back.
2978    fn mark_first_process_running(
2979        &self,
2980        spec: &ModuleSpec,
2981        runtime: &SupervisorRuntimeConfig,
2982        snapshot: &SharedSnapshot,
2983        child: &SupervisedChild,
2984    ) -> Result<(), SuperviseError> {
2985        if let Err(err) = set_running(snapshot, child, &spec.module_id, &runtime.spawn_events) {
2986            self.abandon_unrostered(&spec.module_id);
2987            return Err(err);
2988        }
2989        self.process_liveness
2990            .track(spec.module_id.clone(), Arc::clone(snapshot));
2991        Ok(())
2992    }
2993
2994    /// Start supervising a module declared in daemon configuration.
2995    ///
2996    /// Unlike [`Self::spawn`], this records disabled modules and immediate spawn
2997    /// failures in the supervisor handle so operator-facing `supervisor.list`
2998    /// reflects every configured module while daemon startup continues.
2999    pub fn supervise_configured(
3000        &self,
3001        spec: ModuleSpec,
3002        enabled: bool,
3003    ) -> Result<SupervisedModule, SuperviseError> {
3004        self.supervise_configured_requested(
3005            spec,
3006            enabled,
3007            self.health.clone(),
3008            None,
3009            self.restart_policy,
3010            None,
3011        )
3012    }
3013
3014    /// Supervise a configured module with its own health, drain, and crash
3015    /// budget. The restart policy is per-module because the config file is:
3016    /// `modules.<id>.restart` resolves to a full policy at parse time, and a
3017    /// module that is expensive to restart should not be forced onto the same
3018    /// budget as one that is cheap.
3019    pub fn supervise_configured_with_health(
3020        &self,
3021        spec: ModuleSpec,
3022        enabled: bool,
3023        health: HealthConfig,
3024        drain_timeout_ms: Option<u64>,
3025        restart_policy: RestartPolicy,
3026    ) -> Result<SupervisedModule, SuperviseError> {
3027        self.supervise_configured_requested(
3028            spec,
3029            enabled,
3030            health,
3031            drain_timeout_ms,
3032            restart_policy,
3033            None,
3034        )
3035    }
3036
3037    pub(crate) fn supervise_configured_requested(
3038        &self,
3039        spec: ModuleSpec,
3040        enabled: bool,
3041        health: HealthConfig,
3042        drain_timeout_ms: Option<u64>,
3043        restart_policy: RestartPolicy,
3044        requester: Option<Principal>,
3045    ) -> Result<SupervisedModule, SuperviseError> {
3046        let registered = self.register_configured_with_health(
3047            spec,
3048            enabled,
3049            health,
3050            drain_timeout_ms,
3051            restart_policy,
3052        )?;
3053        self.start_registered_configured(registered, requester)
3054    }
3055
3056    /// Record a configured module as starting or disabled, without launching a
3057    /// process or running its monitor. Bootstrap registers the entire roster
3058    /// before publishing discovery, then activates it once children can read
3059    /// the connection file passed in their `--subc` argument.
3060    pub(crate) fn register_configured_with_health(
3061        &self,
3062        spec: ModuleSpec,
3063        enabled: bool,
3064        health: HealthConfig,
3065        drain_timeout_ms: Option<u64>,
3066        restart_policy: RestartPolicy,
3067    ) -> Result<RegisteredConfiguredModule, SuperviseError> {
3068        validate_spec(&spec)?;
3069        self.establish_identity(&spec);
3070
3071        let mut runtime = self.runtime_config();
3072        runtime.health = health;
3073        runtime.restart_policy = restart_policy;
3074        if let Some(ms) = drain_timeout_ms {
3075            runtime.drain_timeout = Duration::from_millis(ms);
3076            *runtime
3077                .effective_drain_timeout
3078                .lock()
3079                .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.drain_timeout;
3080        }
3081        let snapshot = if enabled {
3082            SupervisorSnapshot::starting()
3083        } else {
3084            SupervisorSnapshot::disabled()
3085        };
3086        #[cfg(target_os = "linux")]
3087        let snapshot = {
3088            let mut snapshot = snapshot;
3089            if enabled {
3090                snapshot.waiting_for_previous_pid =
3091                    runtime.child_roster.previous_instance_pid(&spec.module_id);
3092            }
3093            snapshot
3094        };
3095        let (module, commands) =
3096            self.register_module(&spec, &runtime, Arc::new(Mutex::new(snapshot)));
3097        Ok(RegisteredConfiguredModule {
3098            spec,
3099            runtime,
3100            module,
3101            commands,
3102        })
3103    }
3104
3105    /// Activate a previously registered module. Queued control requests are
3106    /// served by the monitor after the initial configured spawn has settled.
3107    pub(crate) fn start_registered_configured(
3108        &self,
3109        registered: RegisteredConfiguredModule,
3110        requester: Option<Principal>,
3111    ) -> Result<SupervisedModule, SuperviseError> {
3112        let RegisteredConfiguredModule {
3113            spec,
3114            mut runtime,
3115            module,
3116            commands,
3117        } = registered;
3118        runtime.start_requester = requester;
3119        let snapshot = &module.inner.snapshot;
3120        let should_spawn = {
3121            let state = lock_snapshot(snapshot)?;
3122            state.enabled && state.waiting_for_previous_pid.is_none()
3123        };
3124        let child = if should_spawn {
3125            let activity_spawn = runtime.begin_activity_spawn(&spec.module_id);
3126            let spawned = spawn_child(
3127                &spec,
3128                runtime.connection_file_path.as_deref(),
3129                self.supervisor_handle.as_ref(),
3130                &runtime.stderr_ring,
3131                runtime.capture_logs_dir.as_deref(),
3132                &runtime.child_roster,
3133                #[cfg(target_os = "linux")]
3134                runtime.cgroup_placement.as_ref(),
3135            );
3136            #[cfg(test)]
3137            self.test_after_first_spawn.run(&spec.module_id);
3138            match spawned {
3139                Ok(child) => {
3140                    self.mark_first_process_running(&spec, &runtime, snapshot, &child)?;
3141                    if let Some(spawn) = activity_spawn {
3142                        spawn.complete(snapshot);
3143                    }
3144                    Some(child)
3145                }
3146                Err(err) => {
3147                    if runtime.health.critical {
3148                        error!(
3149                            module_id = %spec.module_id,
3150                            program = %spec.program.display(),
3151                            error = %err,
3152                            "critical configured module failed to spawn; marking failed and alerting"
3153                        );
3154                    } else {
3155                        error!(
3156                            module_id = %spec.module_id,
3157                            program = %spec.program.display(),
3158                            error = %err,
3159                            "configured module failed to spawn; marking failed and continuing"
3160                        );
3161                    }
3162                    update_snapshot(snapshot, Some(&spec.module_id), |state| {
3163                        state.state = ModuleState::Failed;
3164                        clear_current_process_facts(state);
3165                    })?;
3166                    None
3167                }
3168            }
3169        } else {
3170            None
3171        };
3172        self.start_monitor(spec, runtime, &module, child, commands);
3173        Ok(module)
3174    }
3175
3176    fn runtime_config(&self) -> SupervisorRuntimeConfig {
3177        let effective_drain_timeout = Arc::new(Mutex::new(self.drain_timeout));
3178        SupervisorRuntimeConfig {
3179            start_requester: None,
3180            pending_activity_restart: false,
3181            provenance_probe: self.provenance_probe.clone(),
3182            scheduled_respawn: Arc::default(),
3183            deferred_reload_reply: Arc::default(),
3184            restart_policy: self.restart_policy,
3185            drain_timeout: self.drain_timeout,
3186            // Shared with this module's roster copy: daemon shutdown waits on
3187            // each child for the module's own drain budget, as resolved now.
3188            child_roster: self
3189                .child_roster
3190                .for_module(Arc::clone(&effective_drain_timeout)),
3191            effective_drain_timeout,
3192            default_drain_timeout: self.drain_timeout,
3193            health: self.health.clone(),
3194            connection_file_path: self.connection_file_path.clone(),
3195            capture_logs_dir: self.capture_logs_dir.clone(),
3196            forwarding: self.forwarding.clone(),
3197            supervisor_handle: self.supervisor_handle.clone(),
3198            stderr_ring: Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default()))),
3199            terminal_ring: Arc::new(Mutex::new(
3200                TerminalRing::new(
3201                    TerminalRingConfig::default(),
3202                    self.daemon_start_clock.started_at_ms(),
3203                )
3204                .with_start_clock(self.daemon_start_clock)
3205                .with_journal(self.terminal_journal.clone())
3206                .with_daemon_shutdown(self.child_roster.shutdown_flag()),
3207            )),
3208            spawn_events: self.spawn_events.clone(),
3209            #[cfg(target_os = "linux")]
3210            cgroup_placement: self.cgroup_placement.clone(),
3211            #[cfg(test)]
3212            test_seed_stale_facts_before_enable_spawn: false,
3213            #[cfg(test)]
3214            test_reload_exit_record_gate: None,
3215        }
3216    }
3217
3218    fn supervised_module(
3219        &self,
3220        spec: ModuleSpec,
3221        runtime: SupervisorRuntimeConfig,
3222        snapshot: SharedSnapshot,
3223        child: Option<SupervisedChild>,
3224    ) -> SupervisedModule {
3225        let (module, commands) = self.register_module(&spec, &runtime, snapshot);
3226        self.start_monitor(spec, runtime, &module, child, commands);
3227        module
3228    }
3229
3230    fn register_module(
3231        &self,
3232        spec: &ModuleSpec,
3233        runtime: &SupervisorRuntimeConfig,
3234        snapshot: SharedSnapshot,
3235    ) -> (SupervisedModule, mpsc::Receiver<SupervisorCommand>) {
3236        let configuration = Arc::new(Mutex::new(SupervisedConfiguration {
3237            spec: spec.clone(),
3238            health: runtime.health.clone(),
3239        }));
3240        let stderr_ring = Arc::clone(&runtime.stderr_ring);
3241        let terminal_ring = Arc::clone(&runtime.terminal_ring);
3242        // The module's OWN policy, which may be its per-module config rather than
3243        // the supervisor-wide one; status must report the budget the supervise
3244        // loop actually enforces.
3245        let restart_policy = runtime.restart_policy;
3246        let effective_drain_timeout = Arc::clone(&runtime.effective_drain_timeout);
3247        let (tx, rx) = mpsc::channel(4);
3248        let module_id = spec.module_id.clone();
3249        let module = SupervisedModule {
3250            inner: Arc::new(SupervisedModuleInner {
3251                module_id: module_id.clone(),
3252                registry: Arc::clone(&self.registry),
3253                snapshot,
3254                configuration,
3255                stderr_ring,
3256                terminal_ring,
3257                commands: tx,
3258                monitor: Mutex::new(None),
3259                restart_policy,
3260                effective_drain_timeout,
3261                provenance_probe: self.provenance_probe.clone(),
3262            }),
3263        };
3264        // Identity gates and the configured mark must precede the roster entry,
3265        // so a client never sees a module without its admission policy.
3266        if let Some(supervisor_handle) = &self.supervisor_handle {
3267            supervisor_handle.insert(module.clone());
3268        }
3269        (module, rx)
3270    }
3271
3272    fn start_monitor(
3273        &self,
3274        spec: ModuleSpec,
3275        runtime: SupervisorRuntimeConfig,
3276        module: &SupervisedModule,
3277        child: Option<SupervisedChild>,
3278        commands: mpsc::Receiver<SupervisorCommand>,
3279    ) {
3280        let monitor = tokio::spawn(supervise_loop(
3281            spec,
3282            runtime,
3283            Arc::clone(&self.registry),
3284            Arc::clone(&self.process_liveness),
3285            Arc::clone(&module.inner.snapshot),
3286            child,
3287            commands,
3288        ));
3289        *module
3290            .inner
3291            .monitor
3292            .lock()
3293            .unwrap_or_else(|p| p.into_inner()) = Some(monitor);
3294    }
3295}
3296
3297impl Default for Supervisor {
3298    fn default() -> Self {
3299        Self::new(Arc::new(Registry::default()), RestartPolicy::default())
3300    }
3301}
3302
3303/// Handle to one supervised child process.
3304#[derive(Clone)]
3305pub struct SupervisedModule {
3306    inner: Arc<SupervisedModuleInner>,
3307}
3308
3309struct SupervisedModuleInner {
3310    module_id: String,
3311    registry: Arc<Registry>,
3312    snapshot: SharedSnapshot,
3313    configuration: Arc<Mutex<SupervisedConfiguration>>,
3314    stderr_ring: Arc<Mutex<StderrRing>>,
3315    terminal_ring: Arc<Mutex<TerminalRing>>,
3316    commands: mpsc::Sender<SupervisorCommand>,
3317    monitor: Mutex<Option<JoinHandle<()>>>,
3318    /// Copied from the supervisor's runtime config at spawn so `status()` can
3319    /// report the restart budget without reaching back into the supervisor. The
3320    /// policy is fixed for the process's lifetime, so a copy cannot drift.
3321    restart_policy: RestartPolicy,
3322    effective_drain_timeout: Arc<Mutex<Duration>>,
3323    provenance_probe: ExecutableIdentityProbe,
3324}
3325
3326impl fmt::Debug for SupervisedModule {
3327    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3328        f.debug_struct("SupervisedModule")
3329            .field("module_id", &self.inner.module_id)
3330            .field("status", &self.status())
3331            .finish_non_exhaustive()
3332    }
3333}
3334
3335impl SupervisedModule {
3336    pub fn module_id(&self) -> &str {
3337        &self.inner.module_id
3338    }
3339
3340    /// Test-only: put one probe miss on the streak, the way
3341    /// `handle_health_probe_failure` does, so tests can assert what a later
3342    /// event does to the streak without driving the whole probe loop.
3343    #[cfg(test)]
3344    pub(crate) fn record_health_probe_failure_for_test(
3345        &self,
3346        detail: &str,
3347    ) -> Result<(), SuperviseError> {
3348        update_snapshot(&self.inner.snapshot, Some(&self.inner.module_id), |state| {
3349            state.health.consecutive_failures = state.health.consecutive_failures.saturating_add(1);
3350            state.health.detail = Some(detail.to_string());
3351        })
3352    }
3353
3354    pub fn state(&self) -> Result<ModuleState, SuperviseError> {
3355        Ok(lock_snapshot(&self.inner.snapshot)?.state)
3356    }
3357
3358    pub(crate) fn registration_end_reason(
3359        &self,
3360    ) -> Result<Option<RegistrationEndReason>, SuperviseError> {
3361        let snapshot = lock_snapshot(&self.inner.snapshot)?;
3362        Ok(match snapshot.state {
3363            ModuleState::Draining if snapshot.draining_to_replace => {
3364                Some(RegistrationEndReason::SupervisorRestart)
3365            }
3366            ModuleState::Draining => Some(RegistrationEndReason::SupervisorStop),
3367            ModuleState::Restarting if snapshot.draining_to_replace => {
3368                Some(RegistrationEndReason::SupervisorRestart)
3369            }
3370            _ => None,
3371        })
3372    }
3373
3374    /// The module's retained stderr, newest lines last.
3375    ///
3376    /// Deliberately NOT on [`Self::status`]: a bounded tail is kilobytes per
3377    /// module, `supervisor.list` renders every module, and putting it in the
3378    /// shared snapshot would make each status read carry a payload almost nobody
3379    /// asked for. Callers that want the text ask for it.
3380    pub fn stderr_tail(
3381        &self,
3382        max_lines: Option<usize>,
3383        max_bytes: Option<usize>,
3384    ) -> StderrTailSnapshot {
3385        self.inner
3386            .stderr_ring
3387            .lock()
3388            .unwrap_or_else(|poisoned| poisoned.into_inner())
3389            .snapshot(max_lines, max_bytes)
3390    }
3391
3392    /// The module's bounded terminal history, oldest retained exit first.
3393    ///
3394    /// The daemon-start stamp distinguishes a quiet supervisor from a replacement
3395    /// daemon whose in-memory history was necessarily reset.
3396    pub fn terminal_history(&self) -> TerminalHistorySnapshot {
3397        self.inner
3398            .terminal_ring
3399            .lock()
3400            .unwrap_or_else(|poisoned| poisoned.into_inner())
3401            .snapshot()
3402    }
3403
3404    /// Retained observations from the current ring and all journal generations.
3405    ///
3406    /// Blocking: this reads the journal files. Async callers use
3407    /// [`Self::read_durable_terminal_history`].
3408    pub fn durable_terminal_history(&self) -> subc_control::TerminalHistory {
3409        durable_terminal_history_of(&self.inner.terminal_ring, &self.inner.module_id)
3410    }
3411
3412    /// [`Self::durable_terminal_history`] on a blocking thread, so the journal
3413    /// read (up to every retained generation) never occupies a runtime worker.
3414    /// Fails only if the blocking task could not finish (runtime shutdown or a
3415    /// panic in the read).
3416    pub(crate) async fn read_durable_terminal_history(
3417        &self,
3418    ) -> Result<subc_control::TerminalHistory, tokio::task::JoinError> {
3419        let terminal_ring = Arc::clone(&self.inner.terminal_ring);
3420        let module_id = self.inner.module_id.clone();
3421        tokio::task::spawn_blocking(move || durable_terminal_history_of(&terminal_ring, &module_id))
3422            .await
3423    }
3424
3425    pub fn status(&self) -> Result<ModuleStatus, SuperviseError> {
3426        self.status_with_snapshot_lock(&self.inner.snapshot, None)
3427            .map(|(status, _)| status)
3428    }
3429
3430    pub(crate) fn record_deliberate_severance(
3431        &self,
3432        identity: ProcessIdentity,
3433    ) -> Result<bool, SuperviseError> {
3434        let mut snapshot = lock_snapshot(&self.inner.snapshot)?;
3435        if snapshot.pid != Some(identity.pid)
3436            || snapshot.process_start_time != Some(identity.start_time)
3437        {
3438            return Ok(false);
3439        }
3440        snapshot.deliberate_severance = Some(identity);
3441        Ok(true)
3442    }
3443
3444    /// Read status for a channel-0 renderer and report a contended snapshot lock.
3445    ///
3446    /// Internal supervision callers use [`Self::status`] so writer-side machinery
3447    /// does not produce reader-observability logs.
3448    pub(crate) fn status_for_control(
3449        &self,
3450        caller: &'static str,
3451    ) -> Result<ModuleStatus, SuperviseError> {
3452        self.status_with_snapshot_lock(&self.inner.snapshot, Some(caller))
3453            .map(|(status, _)| status)
3454    }
3455
3456    fn status_with_snapshot_lock(
3457        &self,
3458        snapshot: &SharedSnapshot,
3459        caller: Option<&'static str>,
3460    ) -> Result<(ModuleStatus, Option<SpawnedFileIdentity>), SuperviseError> {
3461        let mut guard = match caller {
3462            Some(caller) => lock_snapshot_for_control(snapshot, &self.inner.module_id, caller)?,
3463            None => lock_snapshot(snapshot)?,
3464        };
3465        // Read the budget through the pruning path so a reader sees the same
3466        // in-window count the restart decision would use, not a stale total.
3467        let restart_count =
3468            guard.crash_restarts_in_window(self.inner.restart_policy.window, Instant::now());
3469        let snapshot = guard.clone();
3470        drop(guard);
3471        let drain_timeout = *self.inner.effective_drain_timeout.lock().map_err(|_| {
3472            SuperviseError::StatePoisoned {
3473                module_id: Some(self.inner.module_id.clone()),
3474            }
3475        })?;
3476        let registration_active = self
3477            .inner
3478            .registry
3479            .get_module(&self.inner.module_id)
3480            .map_err(SuperviseError::Registry)?
3481            .is_some();
3482        let protocol = snapshot
3483            .spawned_protocol
3484            .unwrap_or(self.declared_protocol()?);
3485        let running_process =
3486            snapshot.enabled && snapshot.state == ModuleState::Running && snapshot.process_alive;
3487        // Registration is the difference between the two protocols and the only
3488        // one: a subc module that has not registered cannot serve a request even
3489        // though its process is up, and a `none` module never registers at all,
3490        // so requiring it there would pin `live` to false for the whole life of
3491        // a perfectly healthy process.
3492        let live = match protocol {
3493            ModuleProtocol::Subc => running_process && registration_active,
3494            ModuleProtocol::None => running_process,
3495        };
3496
3497        Ok((
3498            ModuleStatus {
3499                waiting_for_previous_pid: snapshot.waiting_for_previous_pid,
3500                module_id: self.inner.module_id.clone(),
3501                launch_nonce_source: observed_launch_nonce_source(&snapshot),
3502                state: snapshot.state,
3503                enabled: snapshot.enabled,
3504                process_alive: snapshot.process_alive,
3505                registration_active,
3506                protocol,
3507                live,
3508                restart_count,
3509                lifetime_restarts: snapshot.lifetime_restarts,
3510                spawn_generation: snapshot.spawn_generation,
3511                max_restarts: self.inner.restart_policy.max_restarts,
3512                restart_window: self.inner.restart_policy.window,
3513                drain_timeout,
3514                restart_backoff: self.inner.restart_policy.backoff,
3515                restart_max_backoff: self.inner.restart_policy.max_backoff,
3516                pid: snapshot.reported_pid(),
3517                spawned_at_ms: snapshot.spawned_at_ms,
3518                spawned_from: snapshot.spawned_from,
3519                process_start_time: snapshot.process_start_time,
3520                last_exit: snapshot.last_exit,
3521                health: snapshot.health,
3522            },
3523            snapshot.spawned_file_identity.clone(),
3524        ))
3525    }
3526
3527    #[cfg(test)]
3528    pub(crate) fn hold_snapshot_for_test(
3529        &self,
3530        acquired: std::sync::mpsc::Sender<()>,
3531        hold: Duration,
3532    ) -> std::thread::JoinHandle<()> {
3533        let snapshot = Arc::clone(&self.inner.snapshot);
3534        std::thread::spawn(move || {
3535            let _guard = snapshot.lock().expect("test snapshot lock is not poisoned");
3536            acquired
3537                .send(())
3538                .expect("test receiver waits for snapshot lock");
3539            std::thread::sleep(hold);
3540        })
3541    }
3542
3543    /// The status and the running-image check for `supervisor.provenance`,
3544    /// taken from one status read. The exec acknowledgement can land between
3545    /// two separate reads, and the reply would then pair "no pid yet" with an
3546    /// image observed after the module started, which describes no single
3547    /// moment.
3548    pub(crate) async fn status_and_running_image_agreement(
3549        &self,
3550    ) -> Result<(ModuleStatus, subc_control::RunningImageAgreement), SuperviseError> {
3551        let (status, identity) = self.status_with_snapshot_lock(&self.inner.snapshot, None)?;
3552        let image = self
3553            .inner
3554            .provenance_probe
3555            .observe(
3556                status.pid,
3557                status.spawned_from.as_deref(),
3558                identity,
3559                status.process_start_time,
3560            )
3561            .await;
3562        Ok((status, image))
3563    }
3564
3565    pub(crate) async fn running_image_agreement(&self) -> subc_control::RunningImageAgreement {
3566        let snapshot = match lock_snapshot(&self.inner.snapshot) {
3567            Ok(snapshot) => snapshot.clone(),
3568            Err(_) => {
3569                return subc_control::RunningImageAgreement::Unavailable {
3570                    reason: subc_control::RunningImageUnavailableReason::NotRunning,
3571                };
3572            }
3573        };
3574        self.inner
3575            .provenance_probe
3576            .observe(
3577                snapshot.reported_pid(),
3578                snapshot.spawned_from.as_deref(),
3579                snapshot.spawned_file_identity.clone(),
3580                snapshot.process_start_time,
3581            )
3582            .await
3583    }
3584
3585    /// Memory and CPU time of the module's current process, read now. Only the
3586    /// process the supervisor spawned is read, not processes it has started.
3587    pub(crate) fn child_resource_usage(&self) -> subc_control::ChildResourceUsage {
3588        #[cfg(windows)]
3589        {
3590            let image = lock_snapshot(&self.inner.snapshot)
3591                .ok()
3592                .and_then(|snapshot| {
3593                    snapshot
3594                        .spawned_file_identity
3595                        .as_ref()
3596                        .and_then(|identity| identity.windows_image.clone())
3597                });
3598            match image {
3599                Some(image) => {
3600                    crate::child_resources::read_windows_handle(image.process(), image.start_time())
3601                }
3602                None => subc_control::ChildResourceUsage::Unavailable {
3603                    reason: subc_control::ChildResourceUnavailableReason::NotRunning,
3604                },
3605            }
3606        }
3607        #[cfg(not(windows))]
3608        {
3609            let (pid, start_time) = match lock_snapshot(&self.inner.snapshot) {
3610                Ok(snapshot) => (snapshot.reported_pid(), snapshot.process_start_time),
3611                Err(_) => {
3612                    return subc_control::ChildResourceUsage::Unavailable {
3613                        reason: subc_control::ChildResourceUnavailableReason::Unreadable,
3614                    }
3615                }
3616            };
3617            crate::child_resources::read(pid, start_time)
3618        }
3619    }
3620
3621    pub(crate) fn will_recover_after_connection_loss(&self) -> Result<bool, SuperviseError> {
3622        let mut snapshot = lock_snapshot(&self.inner.snapshot)?;
3623        Ok(match snapshot.state {
3624            ModuleState::Restarting => true,
3625            ModuleState::Failed | ModuleState::Disabled => false,
3626            _ if snapshot.enabled && snapshot.respawn_pending => true,
3627            _ => daemon_will_restart(&mut snapshot, &self.inner.restart_policy, Instant::now()),
3628        })
3629    }
3630
3631    #[cfg(test)]
3632    pub(crate) fn is_warming(&self) -> Result<bool, SuperviseError> {
3633        self.is_warming_with_snapshot_lock(None)
3634    }
3635
3636    pub(crate) fn is_warming_for_control(
3637        &self,
3638        caller: &'static str,
3639    ) -> Result<bool, SuperviseError> {
3640        self.is_warming_with_snapshot_lock(Some(caller))
3641    }
3642
3643    fn is_warming_with_snapshot_lock(
3644        &self,
3645        caller: Option<&'static str>,
3646    ) -> Result<bool, SuperviseError> {
3647        let snapshot = match caller {
3648            Some(caller) => {
3649                lock_snapshot_for_control(&self.inner.snapshot, &self.inner.module_id, caller)?
3650            }
3651            None => lock_snapshot(&self.inner.snapshot)?,
3652        }
3653        .clone();
3654        Ok(matches!(
3655            snapshot.state,
3656            ModuleState::Starting | ModuleState::Running | ModuleState::Restarting
3657        ))
3658    }
3659
3660    /// Drain the module and stop monitoring it.
3661    pub async fn drain(&self) -> Result<(), SuperviseError> {
3662        self.stop().await
3663    }
3664
3665    pub(crate) async fn retire_requested(
3666        &self,
3667        requester: Option<Principal>,
3668    ) -> Result<(), SuperviseError> {
3669        match self.state()? {
3670            ModuleState::Stopped | ModuleState::Failed => return Ok(()),
3671            ModuleState::Starting
3672            | ModuleState::Running
3673            | ModuleState::Unresponsive
3674            | ModuleState::Restarting
3675            | ModuleState::Draining
3676            | ModuleState::Disabled => {}
3677        }
3678
3679        let (reply_tx, reply_rx) = oneshot::channel();
3680        self.inner
3681            .commands
3682            .send(SupervisorCommand::Retire {
3683                requester,
3684                reply: reply_tx,
3685            })
3686            .await
3687            .map_err(|_| SuperviseError::CommandClosed {
3688                module_id: self.inner.module_id.clone(),
3689            })?;
3690        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3691            module_id: self.inner.module_id.clone(),
3692        })?
3693    }
3694
3695    pub async fn stop(&self) -> Result<(), SuperviseError> {
3696        self.stop_requested(None).await
3697    }
3698
3699    pub(crate) async fn stop_requested(
3700        &self,
3701        requester: Option<Principal>,
3702    ) -> Result<(), SuperviseError> {
3703        match self.state()? {
3704            ModuleState::Stopped | ModuleState::Failed => return Ok(()),
3705            ModuleState::Starting
3706            | ModuleState::Running
3707            | ModuleState::Unresponsive
3708            | ModuleState::Restarting
3709            | ModuleState::Draining
3710            | ModuleState::Disabled => {}
3711        }
3712
3713        let (reply_tx, reply_rx) = oneshot::channel();
3714        self.inner
3715            .commands
3716            .send(SupervisorCommand::Drain {
3717                requester,
3718                reply: reply_tx,
3719            })
3720            .await
3721            .map_err(|_| SuperviseError::CommandClosed {
3722                module_id: self.inner.module_id.clone(),
3723            })?;
3724        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3725            module_id: self.inner.module_id.clone(),
3726        })?
3727    }
3728
3729    pub async fn restart(&self, drain_timeout_ms: Option<u64>) -> Result<(), SuperviseError> {
3730        self.restart_requested(drain_timeout_ms, None).await
3731    }
3732
3733    pub(crate) async fn restart_requested(
3734        &self,
3735        drain_timeout_ms: Option<u64>,
3736        requester: Option<Principal>,
3737    ) -> Result<(), SuperviseError> {
3738        let received_at_generation = lock_snapshot(&self.inner.snapshot)?.spawn_generation;
3739        let (reply_tx, reply_rx) = oneshot::channel();
3740        self.inner
3741            .commands
3742            .send(SupervisorCommand::Restart {
3743                requester,
3744                drain_timeout_ms,
3745                received_at_generation,
3746                queued_at: Instant::now(),
3747                reply: reply_tx,
3748            })
3749            .await
3750            .map_err(|_| SuperviseError::CommandClosed {
3751                module_id: self.inner.module_id.clone(),
3752            })?;
3753        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3754            module_id: self.inner.module_id.clone(),
3755        })?
3756    }
3757
3758    /// Blue/green restart: see [`SupervisorCommand::Swap`] and the
3759    /// `supervisor_swap` module. Returns once the swap has cut over (the old
3760    /// process then drains in the background of the supervise loop) or has
3761    /// failed, leaving the old process serving.
3762    pub async fn swap(&self, ready_timeout: Option<Duration>) -> Result<(), SuperviseError> {
3763        self.swap_requested(ready_timeout, None).await
3764    }
3765
3766    pub(crate) async fn swap_requested(
3767        &self,
3768        ready_timeout: Option<Duration>,
3769        requester: Option<Principal>,
3770    ) -> Result<(), SuperviseError> {
3771        let (reply_tx, reply_rx) = oneshot::channel();
3772        self.inner
3773            .commands
3774            .send(SupervisorCommand::Swap {
3775                requester,
3776                ready_timeout,
3777                reply: reply_tx,
3778            })
3779            .await
3780            .map_err(|_| SuperviseError::CommandClosed {
3781                module_id: self.inner.module_id.clone(),
3782            })?;
3783        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3784            module_id: self.inner.module_id.clone(),
3785        })?
3786    }
3787
3788    pub async fn reload(&self) -> Result<(), SuperviseError> {
3789        self.reload_requested(None).await
3790    }
3791
3792    pub(crate) async fn reload_requested(
3793        &self,
3794        requester: Option<Principal>,
3795    ) -> Result<(), SuperviseError> {
3796        let (reply_tx, reply_rx) = oneshot::channel();
3797        self.inner
3798            .commands
3799            .send(SupervisorCommand::Reload {
3800                requester,
3801                reply: reply_tx,
3802            })
3803            .await
3804            .map_err(|_| SuperviseError::CommandClosed {
3805                module_id: self.inner.module_id.clone(),
3806            })?;
3807        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3808            module_id: self.inner.module_id.clone(),
3809        })?
3810    }
3811
3812    pub async fn set_enabled(&self, enabled: bool) -> Result<bool, SuperviseError> {
3813        self.set_enabled_requested(enabled, None).await
3814    }
3815
3816    pub(crate) async fn set_enabled_requested(
3817        &self,
3818        enabled: bool,
3819        requester: Option<Principal>,
3820    ) -> Result<bool, SuperviseError> {
3821        let (reply_tx, reply_rx) = oneshot::channel();
3822        self.inner
3823            .commands
3824            .send(SupervisorCommand::SetEnabled {
3825                requester,
3826                enabled,
3827                reply: reply_tx,
3828            })
3829            .await
3830            .map_err(|_| SuperviseError::CommandClosed {
3831                module_id: self.inner.module_id.clone(),
3832            })?;
3833        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3834            module_id: self.inner.module_id.clone(),
3835        })?
3836    }
3837
3838    /// The current process's protocol, or the configured protocol when down.
3839    /// A rescan stores the next launch spec without changing how an existing
3840    /// process registers, serves routes, is probed, or exits.
3841    pub(crate) fn declared_protocol(&self) -> Result<ModuleProtocol, SuperviseError> {
3842        let configured = self
3843            .inner
3844            .configuration
3845            .lock()
3846            .map_err(|_| SuperviseError::StatePoisoned {
3847                module_id: Some(self.inner.module_id.clone()),
3848            })?
3849            .spec
3850            .protocol;
3851        let state = lock_snapshot(&self.inner.snapshot)?;
3852        Ok(state.spawned_protocol.unwrap_or(configured))
3853    }
3854
3855    pub(crate) fn configuration(&self) -> Result<(ModuleSpec, HealthConfig), SuperviseError> {
3856        let configuration =
3857            self.inner
3858                .configuration
3859                .lock()
3860                .map_err(|_| SuperviseError::StatePoisoned {
3861                    module_id: Some(self.inner.module_id.clone()),
3862                })?;
3863        Ok((configuration.spec.clone(), configuration.health.clone()))
3864    }
3865
3866    /// Replace this module's launch spec, keeping its health and drain policy,
3867    /// the way a rescan does for a changed config entry. The running process is
3868    /// untouched; the next spawn (a restart, or a swap's candidate) uses it.
3869    #[cfg(any(test, feature = "test-support"))]
3870    pub async fn update_spec_for_test(&self, spec: ModuleSpec) -> Result<(), SuperviseError> {
3871        let (_, health) = self.configuration()?;
3872        let drain_timeout_ms = u64::try_from(
3873            self.inner
3874                .effective_drain_timeout
3875                .lock()
3876                .unwrap_or_else(|poisoned| poisoned.into_inner())
3877                .as_millis(),
3878        )
3879        .ok();
3880        self.update_configuration(spec, health, drain_timeout_ms)
3881            .await
3882    }
3883
3884    pub(crate) async fn update_configuration(
3885        &self,
3886        spec: ModuleSpec,
3887        health: HealthConfig,
3888        drain_timeout_ms: Option<u64>,
3889    ) -> Result<(), SuperviseError> {
3890        if spec.module_id != self.inner.module_id {
3891            return Err(SuperviseError::InvalidSpec {
3892                reason: "a supervised module's module_id cannot be changed".to_string(),
3893            });
3894        }
3895        validate_spec(&spec)?;
3896        let (reply_tx, reply_rx) = oneshot::channel();
3897        self.inner
3898            .commands
3899            .send(SupervisorCommand::UpdateConfiguration {
3900                spec: spec.clone(),
3901                health: health.clone(),
3902                drain_timeout_ms,
3903                reply: reply_tx,
3904            })
3905            .await
3906            .map_err(|_| SuperviseError::CommandClosed {
3907                module_id: self.inner.module_id.clone(),
3908            })?;
3909        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3910            module_id: self.inner.module_id.clone(),
3911        })?;
3912        let mut configuration =
3913            self.inner
3914                .configuration
3915                .lock()
3916                .map_err(|_| SuperviseError::StatePoisoned {
3917                    module_id: Some(self.inner.module_id.clone()),
3918                })?;
3919        configuration.spec = spec;
3920        configuration.health = health;
3921        Ok(())
3922    }
3923}
3924
3925impl Drop for SupervisedModuleInner {
3926    fn drop(&mut self) {
3927        let Ok(mut monitor) = self.monitor.lock() else {
3928            return;
3929        };
3930        if let Some(monitor) = monitor.as_ref().filter(|monitor| !monitor.is_finished()) {
3931            let _ = update_snapshot(&self.snapshot, Some(&self.module_id), |state| {
3932                state.state = ModuleState::Stopped;
3933                clear_current_process_facts(state);
3934            });
3935            monitor.abort();
3936        }
3937        let _ = monitor.take();
3938    }
3939}
3940
3941#[derive(Debug)]
3942enum SupervisorCommand {
3943    Drain {
3944        requester: Option<Principal>,
3945        reply: oneshot::Sender<Result<(), SuperviseError>>,
3946    },
3947    Retire {
3948        requester: Option<Principal>,
3949        reply: oneshot::Sender<Result<(), SuperviseError>>,
3950    },
3951    Restart {
3952        requester: Option<Principal>,
3953        /// Operator override for this one restart's drain budget, in ms. `None`
3954        /// uses the module's configured/default budget; `Some(0)` cuts
3955        /// immediately (wedge bounce: a stuck request never settles, so
3956        /// waiting only delays recovery).
3957        drain_timeout_ms: Option<u64>,
3958        /// The module's `spawn_generation` when the request was received, before
3959        /// it waited in the command queue. A queued restart whose module has
3960        /// since spawned a newer process is already satisfied (see the handler).
3961        received_at_generation: u64,
3962        /// When the request entered the command queue, so the handler can log
3963        /// how long it waited behind the loop's other work.
3964        queued_at: Instant,
3965        reply: oneshot::Sender<Result<(), SuperviseError>>,
3966    },
3967    Reload {
3968        requester: Option<Principal>,
3969        reply: oneshot::Sender<Result<(), SuperviseError>>,
3970    },
3971    SetEnabled {
3972        requester: Option<Principal>,
3973        enabled: bool,
3974        reply: oneshot::Sender<Result<bool, SuperviseError>>,
3975    },
3976    UpdateConfiguration {
3977        spec: ModuleSpec,
3978        health: HealthConfig,
3979        /// Per-module drain override from the new config; `None` re-resolves to
3980        /// the supervisor-wide default.
3981        drain_timeout_ms: Option<u64>,
3982        reply: oneshot::Sender<()>,
3983    },
3984    Swap {
3985        requester: Option<Principal>,
3986        /// How long the candidate may take to register and declare itself
3987        /// ready. `None` uses [`DEFAULT_SWAP_READY_TIMEOUT`].
3988        ready_timeout: Option<Duration>,
3989        /// Answered at cutover or failure; the incumbent's drain follows.
3990        reply: oneshot::Sender<Result<(), SuperviseError>>,
3991    },
3992}
3993
3994#[derive(Debug)]
3995pub enum SuperviseError {
3996    InvalidSpec {
3997        reason: String,
3998    },
3999    Spawn {
4000        program: PathBuf,
4001        source: io::Error,
4002        cgroup_path: Option<PathBuf>,
4003    },
4004    Cgroup {
4005        module_id: String,
4006        source: io::Error,
4007    },
4008    /// CSPRNG failure generating a reserved module's launch nonce. Fail loud rather
4009    /// than spawn a reserved module without its identity binding.
4010    LaunchNonce {
4011        reason: String,
4012    },
4013    Wait {
4014        module_id: String,
4015        source: io::Error,
4016    },
4017    Kill {
4018        module_id: String,
4019        source: io::Error,
4020    },
4021    Forwarding(ForwardingError),
4022    Registry(RegistryError),
4023    ReloadUnavailable {
4024        module_id: String,
4025        reason: String,
4026    },
4027    /// An operator restart/reload was requested for a module that is currently
4028    /// disabled. Restart/reload cycle a *running* module; a disabled module must
4029    /// be explicitly re-enabled (set_enabled(true)) rather than silently started
4030    /// by a restart, so these commands are rejected instead of re-enabling it.
4031    Disabled {
4032        module_id: String,
4033    },
4034    ReloadFailed {
4035        module_id: String,
4036        reason: String,
4037    },
4038    RegistrationStillActive {
4039        module_id: String,
4040        waited: Duration,
4041    },
4042    StatePoisoned {
4043        module_id: Option<String>,
4044    },
4045    CommandClosed {
4046        module_id: String,
4047    },
4048    /// A restart or reload arrived while a swap's candidate was warming. The
4049    /// swap owns the module until it cuts over or fails; a stop or disable
4050    /// would have aborted it instead.
4051    SwapInProgress {
4052        module_id: String,
4053    },
4054    /// A swap was refused before anything was spawned.
4055    SwapRefused {
4056        module_id: String,
4057        reason: SwapRefusal,
4058    },
4059    /// A swap spawned a candidate and gave up on it. The candidate has been
4060    /// killed and its slot freed; the incumbent was left serving and was never
4061    /// drained, except in the one `CutoverLost` case described on that arm.
4062    SwapFailed {
4063        module_id: String,
4064        arm: SwapFailureArm,
4065        detail: String,
4066        /// How the candidate exited, when it exited on its own before the
4067        /// supervisor gave up on it.
4068        candidate_exit: Option<ExitReport>,
4069    },
4070}
4071
4072/// Why a swap was refused before a candidate was spawned.
4073#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4074pub enum SwapRefusal {
4075    /// The module's config does not declare `overlap: "safe"`.
4076    OverlapExclusive,
4077    /// The module is not registered, so there is no incumbent to keep serving
4078    /// and nothing a swap would improve on; a plain restart is the tool.
4079    NotRegistered,
4080    /// The module does not speak the subc wire, so a candidate could never
4081    /// register or declare itself ready.
4082    ProtocolNone,
4083    /// The supervisor lacks the forwarding table (to cut routes over) or the
4084    /// shared handle (to admit the candidate's HELLO) that a swap needs.
4085    NotConfigured,
4086    /// A swap is already open for this module.
4087    AlreadySwapping,
4088}
4089
4090impl SwapRefusal {
4091    pub fn as_str(self) -> &'static str {
4092        match self {
4093            Self::OverlapExclusive => "overlap_exclusive",
4094            Self::NotRegistered => "not_registered",
4095            Self::ProtocolNone => "protocol_none",
4096            Self::NotConfigured => "not_configured",
4097            Self::AlreadySwapping => "already_swapping",
4098        }
4099    }
4100}
4101
4102/// Which failure arm ended a swap. Every arm but one leaves the incumbent
4103/// serving and undrained; see `CutoverLost`.
4104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4105pub enum SwapFailureArm {
4106    /// The candidate process could not be started.
4107    SpawnFailed,
4108    /// The candidate did not register within the readiness budget.
4109    NeverRegistered,
4110    /// The candidate registered but did not declare itself ready in time.
4111    NeverReady,
4112    /// The candidate exited before cutover.
4113    CandidateExited,
4114    /// The candidate declared itself ready but failed its health probe.
4115    CandidateUnhealthy,
4116    /// An operator stop, disable or retire arrived while the candidate warmed.
4117    /// The candidate was killed and the operator's command then carried out on
4118    /// the incumbent.
4119    Interrupted,
4120    /// The candidate's connection closed at the moment of cutover. If it
4121    /// closed before forwarding moved, the incumbent is untouched. If it closed
4122    /// between the forwarding and registry halves of cutover, forwarding can no
4123    /// longer route to the incumbent, so the module is restarted plainly.
4124    CutoverLost,
4125}
4126
4127impl SwapFailureArm {
4128    pub fn as_str(self) -> &'static str {
4129        match self {
4130            Self::SpawnFailed => "spawn_failed",
4131            Self::NeverRegistered => "never_registered",
4132            Self::NeverReady => "never_ready",
4133            Self::CandidateExited => "candidate_exited",
4134            Self::CandidateUnhealthy => "candidate_unhealthy",
4135            Self::Interrupted => "interrupted",
4136            Self::CutoverLost => "cutover_lost",
4137        }
4138    }
4139}
4140
4141impl fmt::Display for SuperviseError {
4142    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4143        match self {
4144            Self::InvalidSpec { reason } => write!(f, "invalid module spec: {reason}"),
4145            Self::Spawn {
4146                program,
4147                source,
4148                cgroup_path: Some(cgroup_path),
4149            } => write!(
4150                f,
4151                "failed to place module in cgroup '{}' while spawning '{}': {source}",
4152                cgroup_path.display(),
4153                program.display()
4154            ),
4155            Self::Spawn {
4156                program,
4157                source,
4158                cgroup_path: None,
4159            } => write!(
4160                f,
4161                "failed to spawn module '{}': {source}",
4162                program.display()
4163            ),
4164            Self::Cgroup { module_id, source } => {
4165                write!(
4166                    f,
4167                    "failed to prepare cgroup for module '{module_id}': {source}"
4168                )
4169            }
4170            Self::LaunchNonce { reason } => {
4171                write!(
4172                    f,
4173                    "failed to generate reserved-module launch nonce: {reason}"
4174                )
4175            }
4176            Self::Wait { module_id, source } => {
4177                write!(f, "failed to wait for module '{module_id}': {source}")
4178            }
4179            Self::Kill { module_id, source } => {
4180                write!(f, "failed to kill module '{module_id}': {source}")
4181            }
4182            Self::Forwarding(err) => write!(f, "forwarding error: {err}"),
4183            Self::Registry(err) => write!(f, "registry error: {err}"),
4184            Self::ReloadUnavailable { module_id, reason } => {
4185                write!(f, "reload unavailable for module '{module_id}': {reason}")
4186            }
4187            Self::Disabled { module_id } => {
4188                write!(
4189                    f,
4190                    "module '{module_id}' is disabled; enable it before restart or reload"
4191                )
4192            }
4193            Self::ReloadFailed { module_id, reason } => {
4194                write!(f, "reload failed for module '{module_id}': {reason}")
4195            }
4196            Self::RegistrationStillActive { module_id, waited } => write!(
4197                f,
4198                "module '{module_id}' registration remained active after waiting {waited:?}"
4199            ),
4200            Self::StatePoisoned { module_id } => match module_id {
4201                Some(module_id) => {
4202                    write!(f, "supervisor state for module '{module_id}' was poisoned")
4203                }
4204                None => write!(f, "supervisor state was poisoned"),
4205            },
4206            Self::CommandClosed { module_id } => {
4207                write!(
4208                    f,
4209                    "supervisor command channel for module '{module_id}' is closed"
4210                )
4211            }
4212            Self::SwapInProgress { module_id } => write!(
4213                f,
4214                "module '{module_id}' is being swapped; retry once the swap has cut over or failed, or stop the module to abort the swap"
4215            ),
4216            Self::SwapRefused { module_id, reason } => match reason {
4217                SwapRefusal::OverlapExclusive => write!(
4218                    f,
4219                    "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"
4220                ),
4221                SwapRefusal::NotRegistered => write!(
4222                    f,
4223                    "module '{module_id}' is not registered, so there is no serving process to keep while a replacement warms; use a plain restart"
4224                ),
4225                SwapRefusal::ProtocolNone => write!(
4226                    f,
4227                    "module '{module_id}' is protocol: \"none\" and never registers, so a swap could never see its replacement become ready; use a plain restart"
4228                ),
4229                SwapRefusal::NotConfigured => write!(
4230                    f,
4231                    "module '{module_id}' cannot be swapped: the supervisor was built without the forwarding table or shared handle a swap needs"
4232                ),
4233                SwapRefusal::AlreadySwapping => {
4234                    write!(f, "module '{module_id}' is already being swapped")
4235                }
4236            },
4237            Self::SwapFailed {
4238                module_id,
4239                arm,
4240                detail,
4241                ..
4242            } => write!(
4243                f,
4244                "swap of module '{module_id}' failed ({}): {detail}; the running process was left serving",
4245                arm.as_str()
4246            ),
4247        }
4248    }
4249}
4250
4251impl Error for SuperviseError {
4252    fn source(&self) -> Option<&(dyn Error + 'static)> {
4253        match self {
4254            Self::Spawn { source, .. }
4255            | Self::Cgroup { source, .. }
4256            | Self::Wait { source, .. }
4257            | Self::Kill { source, .. } => Some(source),
4258            Self::Forwarding(err) => Some(err),
4259            Self::Registry(err) => Some(err),
4260            Self::LaunchNonce { .. }
4261            | Self::InvalidSpec { .. }
4262            | Self::ReloadUnavailable { .. }
4263            | Self::Disabled { .. }
4264            | Self::ReloadFailed { .. }
4265            | Self::RegistrationStillActive { .. }
4266            | Self::StatePoisoned { .. }
4267            | Self::CommandClosed { .. }
4268            | Self::SwapInProgress { .. }
4269            | Self::SwapRefused { .. }
4270            | Self::SwapFailed { .. } => None,
4271        }
4272    }
4273}
4274
4275pub(crate) fn validate_spec(spec: &ModuleSpec) -> Result<(), SuperviseError> {
4276    if spec.module_id.trim().is_empty() {
4277        return Err(SuperviseError::InvalidSpec {
4278            reason: "module_id must not be empty".to_string(),
4279        });
4280    }
4281
4282    Ok(())
4283}
4284
4285#[derive(Debug, Default)]
4286struct HealthProbeRuntime {
4287    configured_health: Option<HealthConfig>,
4288    registered_connection: Option<crate::ConnectionId>,
4289    advertised: bool,
4290    next_probe_at: Option<Instant>,
4291    probe_index: u64,
4292}
4293
4294fn running_protocol(spec: &ModuleSpec, snapshot: &SharedSnapshot) -> ModuleProtocol {
4295    lock_snapshot(snapshot)
4296        .ok()
4297        .and_then(|state| state.spawned_protocol)
4298        .unwrap_or(spec.protocol)
4299}
4300
4301impl HealthProbeRuntime {
4302    fn refresh_registration(
4303        &mut self,
4304        spec: &ModuleSpec,
4305        runtime: &SupervisorRuntimeConfig,
4306        registry: &Registry,
4307        snapshot: &SharedSnapshot,
4308    ) {
4309        if self.configured_health.as_ref() != Some(&runtime.health) {
4310            self.configured_health = Some(runtime.health.clone());
4311            self.next_probe_at = None;
4312            self.registered_connection = None;
4313            self.probe_index = 0;
4314        }
4315        // A non-wire process never registers. Only an explicitly configured
4316        // HTTP endpoint can arm its health probe; an absent HELLO is not a
4317        // health failure for that kind of process.
4318        if running_protocol(spec, snapshot) == ModuleProtocol::None {
4319            self.registered_connection = None;
4320            self.advertised = runtime.health.http.is_some();
4321            if !self.advertised {
4322                self.next_probe_at = None;
4323                let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4324                    let unknown = ModuleHealthStatus::default();
4325                    if state.health != unknown {
4326                        state.health = unknown;
4327                    }
4328                });
4329            } else if self.next_probe_at.is_none() {
4330                self.next_probe_at = Some(
4331                    Instant::now()
4332                        + jittered_health_delay(&spec.module_id, 0, runtime.health.cadence),
4333                );
4334            }
4335            return;
4336        }
4337
4338        let registration = match registry.get_module(&spec.module_id) {
4339            Ok(registration) => registration,
4340            Err(err) => {
4341                warn!(module_id = %spec.module_id, error = %err, "health prober could not read registry");
4342                self.advertised = false;
4343                self.next_probe_at = None;
4344                return;
4345            }
4346        };
4347
4348        let Some(registration) = registration else {
4349            self.registered_connection = None;
4350            self.advertised = false;
4351            self.next_probe_at = None;
4352            return;
4353        };
4354
4355        let advertised = registration
4356            .control_ops
4357            .iter()
4358            .any(|op| op == MODULE_CONTROL_OP_HEALTH_CHECK);
4359        if !advertised {
4360            self.registered_connection = Some(registration.connection_id);
4361            self.advertised = false;
4362            self.next_probe_at = None;
4363            let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4364                if state.health.status != SupervisorHealthStatus::Unknown
4365                    || state.health.consecutive_failures != 0
4366                    || state.health.last_probe_ms.is_some()
4367                    || state.health.detail.is_some()
4368                    || state.health.metrics.is_some()
4369                {
4370                    state.health.status = SupervisorHealthStatus::Unknown;
4371                    state.health.consecutive_failures = 0;
4372                    state.health.last_probe_ms = None;
4373                    state.health.detail = None;
4374                    state.health.metrics = None;
4375                }
4376            });
4377            return;
4378        }
4379
4380        let reregistered = self.registered_connection != Some(registration.connection_id);
4381        self.registered_connection = Some(registration.connection_id);
4382        self.advertised = true;
4383        if reregistered || self.next_probe_at.is_none() {
4384            self.probe_index = 0;
4385            self.next_probe_at = Some(
4386                Instant::now() + jittered_health_delay(&spec.module_id, 0, runtime.health.cadence),
4387            );
4388            let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4389                state.health.status = SupervisorHealthStatus::Unknown;
4390                state.health.consecutive_failures = 0;
4391                state.health.detail = None;
4392                state.health.metrics = None;
4393            });
4394        }
4395    }
4396
4397    fn wake_after(&self) -> Option<Duration> {
4398        if !self.advertised {
4399            return None;
4400        }
4401        self.next_probe_at
4402            .map(|next| next.saturating_duration_since(Instant::now()))
4403    }
4404
4405    fn due(&self) -> bool {
4406        self.advertised
4407            && self
4408                .next_probe_at
4409                .is_some_and(|next| Instant::now() >= next)
4410    }
4411
4412    fn schedule_next(&mut self, spec: &ModuleSpec, cadence: Duration) {
4413        self.probe_index = self.probe_index.wrapping_add(1);
4414        self.next_probe_at = Some(
4415            Instant::now() + jittered_health_delay(&spec.module_id, self.probe_index, cadence),
4416        );
4417    }
4418}
4419
4420/// What a failed health probe actually OBSERVED, kept apart from how it reads.
4421///
4422/// This was a struct with a single `message: String`, and every one of the
4423/// fifteen construction sites collapsed into it. Each site knows exactly what it
4424/// saw -- the lane is gone, the module did not answer in time, the module
4425/// answered with the wrong thing -- and `handle_health_probe_failure` then
4426/// treated all of them identically: increment a counter, compare to a threshold,
4427/// restart the module. THE DISTINCTION EXISTED AT EVERY CALL SITE AND WAS
4428/// DESTROYED BEFORE THE DECISION THAT NEEDED IT.
4429///
4430/// The distinction that matters is not severity, it is EVIDENTIAL WEIGHT:
4431///
4432/// * `LaneDead` is PROOF. The module's control connection is gone; nothing will
4433///   answer on it again.
4434/// * `NoAnswer` is ABSENCE OF EVIDENCE. It is consistent with a wedged module
4435///   AND with a perfectly healthy one that lost a CPU race -- which is what
4436///   happens under machine load, and is how this supervisor killed a healthy
4437///   module three times in one day.
4438/// * `BadAnswer` proves the module is ALIVE. It replied; the reply was wrong.
4439///   Restarting on it is defensible, but it is not the silence case and should
4440///   never be counted as one.
4441/// * `Misconfigured` is a daemon-side fault. The module has not been asked
4442///   anything, so it cannot be evidence about the module at all.
4443///
4444/// The asymmetry is the whole point: under saturation the WEAKEST signal is the
4445/// one that fires most often, and while every variant collapsed into one string
4446/// it carried the same weight as the strongest.
4447///
4448/// LIVE BEHAVIOUR TODAY, stated here because this doc block describes the
4449/// DESIGN and a reader stopping at it gets the build backwards: the restart
4450/// decision does NOT yet consult this classification -- consecutive `NoAnswer`
4451/// probes still increment the failure streak and drive escalation at the
4452/// threshold (see `is_proof_of_death` below for why that is deliberate and
4453/// what gates the change). Absence of evidence restarts modules today.
4454#[derive(Debug)]
4455enum HealthProbeEvidence {
4456    /// The module's control lane is gone. Proof of death.
4457    LaneDead,
4458    /// No reply within the deadline. Proves nothing about the module's state.
4459    NoAnswer,
4460    /// The module replied, but not with a usable health report. Proves it is alive.
4461    BadAnswer,
4462    /// The daemon could not ask. Says nothing about the module.
4463    Misconfigured,
4464}
4465
4466#[derive(Debug)]
4467struct HealthProbeError {
4468    evidence: HealthProbeEvidence,
4469    message: String,
4470}
4471
4472impl HealthProbeError {
4473    fn lane_dead(message: impl Into<String>) -> Self {
4474        Self::with(HealthProbeEvidence::LaneDead, message)
4475    }
4476
4477    fn no_answer(message: impl Into<String>) -> Self {
4478        Self::with(HealthProbeEvidence::NoAnswer, message)
4479    }
4480
4481    fn bad_answer(message: impl Into<String>) -> Self {
4482        Self::with(HealthProbeEvidence::BadAnswer, message)
4483    }
4484
4485    fn misconfigured(message: impl Into<String>) -> Self {
4486        Self::with(HealthProbeEvidence::Misconfigured, message)
4487    }
4488
4489    fn with(evidence: HealthProbeEvidence, message: impl Into<String>) -> Self {
4490        Self {
4491            evidence,
4492            message: message.into(),
4493        }
4494    }
4495
4496    /// Whether this observation is proof the module cannot serve.
4497    ///
4498    /// Only `LaneDead` qualifies. `NoAnswer` is deliberately excluded: it is the
4499    /// variant that fires under CPU starvation, and treating it as proof is the
4500    /// defect this enum exists to make impossible to reintroduce silently.
4501    ///
4502    /// NOT YET CONSULTED BY THE RESTART DECISION, deliberately. Requiring proof
4503    /// to restart also needs a bound for the case it excludes -- a genuinely
4504    /// wedged module, alive but never answering -- and that bound must come from
4505    /// the distribution of real late-answer latencies, which nothing measures
4506    /// yet. Landing the classification first makes the later change a one-line
4507    /// decision against evidence that already exists, rather than two unproven
4508    /// changes at once.
4509    #[allow(dead_code)]
4510    fn is_proof_of_death(&self) -> bool {
4511        matches!(self.evidence, HealthProbeEvidence::LaneDead)
4512    }
4513
4514    /// Short stable label for logs and the health snapshot.
4515    ///
4516    /// An operator reading `ck health` currently cannot tell "the module is gone"
4517    /// from "the module did not answer in five seconds", because both render as
4518    /// prose in the same field. These labels are what make the two
4519    /// distinguishable at a glance, and they are what a later restart-policy
4520    /// change will be argued from.
4521    fn label(&self) -> &'static str {
4522        match self.evidence {
4523            HealthProbeEvidence::LaneDead => "lane-dead",
4524            HealthProbeEvidence::NoAnswer => "no-answer",
4525            HealthProbeEvidence::BadAnswer => "bad-answer",
4526            HealthProbeEvidence::Misconfigured => "daemon-misconfigured",
4527        }
4528    }
4529}
4530
4531impl fmt::Display for HealthProbeError {
4532    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4533        f.write_str(&self.message)
4534    }
4535}
4536
4537async fn run_health_probe_cycle(
4538    spec: &ModuleSpec,
4539    runtime: &SupervisorRuntimeConfig,
4540    registry: &Registry,
4541    process_liveness: &SupervisorProcessLiveness,
4542    snapshot: &SharedSnapshot,
4543    child: &mut Option<SupervisedChild>,
4544) {
4545    let now_ms = unix_ms_now();
4546    let http = (running_protocol(spec, snapshot) == ModuleProtocol::None)
4547        .then_some(runtime.health.http.as_deref())
4548        .flatten();
4549    let result = match http {
4550        Some(url) => probe_http_health(url, runtime.health.deadline).await,
4551        None => probe_module_health(&spec.module_id, runtime, None).await,
4552    };
4553    match result {
4554        Ok(report) => {
4555            handle_health_report(
4556                spec,
4557                runtime,
4558                registry,
4559                process_liveness,
4560                snapshot,
4561                child,
4562                report,
4563                now_ms,
4564            )
4565            .await;
4566        }
4567        Err(err) => {
4568            if http.is_some() {
4569                let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4570                    state.health.status = SupervisorHealthStatus::Failing;
4571                });
4572            }
4573            handle_health_probe_failure(
4574                spec,
4575                runtime,
4576                registry,
4577                process_liveness,
4578                snapshot,
4579                child,
4580                err,
4581                now_ms,
4582            )
4583            .await;
4584        }
4585    }
4586}
4587
4588pub(crate) struct HttpProbeTarget<'a> {
4589    address: std::net::SocketAddr,
4590    localhost: bool,
4591    authority: &'a str,
4592    path: String,
4593}
4594
4595/// Resolve only literal loopback endpoints, without DNS, redirects, proxies,
4596/// or TLS. A URL cannot turn a local health check into an outbound connection.
4597pub(crate) fn parse_http_probe_url(url: &str) -> Result<HttpProbeTarget<'_>, String> {
4598    if url.bytes().any(|b| b <= b' ' || b == 127) || url.contains('#') {
4599        return Err("must not contain whitespace, controls, or a fragment".into());
4600    }
4601    let rest = url
4602        .strip_prefix("http://")
4603        .ok_or("must use plain http://")?;
4604    let split = rest.find(['/', '?']).unwrap_or(rest.len());
4605    let (authority, suffix) = rest.split_at(split);
4606    let (host, port) = if let Some(rest) = authority.strip_prefix("[::1]") {
4607        ("::1", rest)
4608    } else {
4609        let split = authority.find(':').unwrap_or(authority.len());
4610        authority.split_at(split)
4611    };
4612    let ip: std::net::IpAddr = match host {
4613        "127.0.0.1" | "localhost" => std::net::Ipv4Addr::LOCALHOST.into(),
4614        "::1" => std::net::Ipv6Addr::LOCALHOST.into(),
4615        _ => return Err("host must be 127.0.0.1, [::1], or localhost".into()),
4616    };
4617    let port = if port.is_empty() {
4618        80
4619    } else {
4620        port.strip_prefix(':')
4621            .and_then(|p| p.parse::<u16>().ok())
4622            .filter(|p| *p > 0)
4623            .ok_or("must have a valid nonzero TCP port")?
4624    };
4625    let path = if suffix.is_empty() {
4626        "/".into()
4627    } else if suffix.starts_with('?') {
4628        format!("/{suffix}")
4629    } else {
4630        suffix.into()
4631    };
4632    Ok(HttpProbeTarget {
4633        address: std::net::SocketAddr::new(ip, port),
4634        localhost: host == "localhost",
4635        authority,
4636        path,
4637    })
4638}
4639
4640async fn probe_http_health(
4641    url: &str,
4642    deadline: Duration,
4643) -> Result<HealthReport, HealthProbeError> {
4644    use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader};
4645    let target = parse_http_probe_url(url).map_err(HealthProbeError::misconfigured)?;
4646    // Keep partial diagnostics outside the timed future so cancellation does
4647    // not discard a status line or body bytes already received.
4648    let mut response_status = String::new();
4649    let mut body = Vec::new();
4650    let probe = async {
4651        // Resolve localhost ourselves so a hosts-file override cannot turn
4652        // this into an outbound request, while IPv6-only local servers work.
4653        let connection = match tokio::net::TcpStream::connect(target.address).await {
4654            Err(_) if target.localhost => {
4655                tokio::net::TcpStream::connect((
4656                    std::net::Ipv6Addr::LOCALHOST,
4657                    target.address.port(),
4658                ))
4659                .await
4660            }
4661            result => result,
4662        };
4663        let mut stream = connection.map_err(|error| {
4664            HealthProbeError::no_answer(format!("HTTP connect failed: {error}"))
4665        })?;
4666        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
4667            .map_err(|error| HealthProbeError::no_answer(format!("HTTP write failed: {error}")))?;
4668        let mut reader = BufReader::new(stream);
4669        let mut budget = 16 * 1024;
4670        let status = http_line(&mut reader, &mut budget).await?;
4671        let mut words = status.split_ascii_whitespace();
4672        let version = words.next();
4673        let code = words
4674            .next()
4675            .filter(|word| word.len() == 3)
4676            .and_then(|word| word.parse::<u16>().ok());
4677        if !matches!(version, Some("HTTP/1.1" | "HTTP/1.0"))
4678            || !code.is_some_and(|code| (100..600).contains(&code))
4679        {
4680            return Err(HealthProbeError::bad_answer(format!(
4681                "invalid HTTP status: {status}"
4682            )));
4683        }
4684        let code = code.expect("validated status code");
4685        response_status = status.clone();
4686        let mut length = None;
4687        let mut chunked = false;
4688        loop {
4689            let line = http_line(&mut reader, &mut budget).await?;
4690            if line.is_empty() {
4691                break;
4692            }
4693            if let Some((name, value)) = line.split_once(':') {
4694                if name.eq_ignore_ascii_case("content-length") {
4695                    length = Some(value.trim().parse::<u64>().map_err(|_| {
4696                        HealthProbeError::bad_answer("invalid HTTP Content-Length")
4697                    })?);
4698                } else if name.eq_ignore_ascii_case("transfer-encoding") {
4699                    chunked = value.trim().eq_ignore_ascii_case("chunked");
4700                }
4701            }
4702        }
4703        if chunked {
4704            while body.len() < 200 {
4705                let line = http_line(&mut reader, &mut budget).await?;
4706                let size = u64::from_str_radix(line.split(';').next().unwrap_or("").trim(), 16)
4707                    .map_err(|_| HealthProbeError::bad_answer("invalid HTTP chunk size"))?;
4708                if size == 0 {
4709                    break;
4710                }
4711                let count = size.min((200 - body.len()) as u64) as usize;
4712                let start = body.len();
4713                (&mut reader)
4714                    .take(count as u64)
4715                    .read_to_end(&mut body)
4716                    .await
4717                    .map_err(|error| {
4718                        HealthProbeError::no_answer(format!("HTTP body read failed: {error}"))
4719                    })?;
4720                if body.len() - start != count {
4721                    return Err(HealthProbeError::bad_answer("truncated HTTP chunk"));
4722                }
4723                if size > count as u64 || body.len() == 200 {
4724                    break;
4725                }
4726                if !http_line(&mut reader, &mut budget).await?.is_empty() {
4727                    return Err(HealthProbeError::bad_answer("invalid HTTP chunk delimiter"));
4728                }
4729            }
4730        } else {
4731            reader
4732                .take(length.unwrap_or(200).min(200))
4733                .read_to_end(&mut body)
4734                .await
4735                .map_err(|error| {
4736                    HealthProbeError::no_answer(format!("HTTP body read failed: {error}"))
4737                })?;
4738        }
4739        if (200..300).contains(&code) {
4740            Ok(HealthReport::ok())
4741        } else {
4742            Err(HealthProbeError::bad_answer(
4743                "HTTP health endpoint returned non-2xx",
4744            ))
4745        }
4746    };
4747    let mut result = timeout(deadline, probe).await.unwrap_or_else(|_| {
4748        Err(HealthProbeError::no_answer(format!(
4749            "HTTP probe timed out after {deadline:?}"
4750        )))
4751    });
4752    if let Err(error) = &mut result {
4753        if !response_status.is_empty() {
4754            error.message = format!(
4755                "{}; {response_status}: {}",
4756                error.message,
4757                String::from_utf8_lossy(&body)
4758            );
4759        }
4760    }
4761    result
4762}
4763
4764async fn http_line(
4765    reader: &mut tokio::io::BufReader<tokio::net::TcpStream>,
4766    remaining: &mut usize,
4767) -> Result<String, HealthProbeError> {
4768    use tokio::io::{AsyncBufReadExt, AsyncReadExt};
4769    let mut line = Vec::new();
4770    (&mut *reader)
4771        .take(*remaining as u64)
4772        .read_until(b'\n', &mut line)
4773        .await
4774        .map_err(|error| {
4775            HealthProbeError::no_answer(format!("HTTP header read failed: {error}"))
4776        })?;
4777    *remaining -= line.len();
4778    if !line.ends_with(b"\r\n") {
4779        return Err(HealthProbeError::bad_answer(
4780            "HTTP headers are incomplete or exceed 16 KiB",
4781        ));
4782    }
4783    line.truncate(line.len() - 2);
4784    String::from_utf8(line).map_err(|_| HealthProbeError::bad_answer("HTTP header is not UTF-8"))
4785}
4786
4787async fn probe_module_health(
4788    module_id: &str,
4789    runtime: &SupervisorRuntimeConfig,
4790    drain_deadline: Option<Instant>,
4791) -> Result<HealthReport, HealthProbeError> {
4792    let Some(forwarding) = runtime.forwarding.as_ref() else {
4793        return Err(HealthProbeError::misconfigured(
4794            "supervisor was not configured with a forwarding table",
4795        ));
4796    };
4797    let probe_started_at = Instant::now();
4798    let mut deadline = probe_started_at + runtime.health.deadline;
4799    if let Some(drain_deadline) = drain_deadline {
4800        deadline = deadline.min(drain_deadline);
4801    }
4802    let pending = if drain_deadline.is_some() {
4803        forwarding.begin_drain_health_probe_rpc_for(
4804            module_id,
4805            MODULE_CONTROL_OP_HEALTH_CHECK,
4806            probe_started_at,
4807            deadline,
4808        )
4809    } else {
4810        forwarding.begin_health_probe_rpc_for(
4811            module_id,
4812            MODULE_CONTROL_OP_HEALTH_CHECK,
4813            probe_started_at,
4814            deadline,
4815        )
4816    }
4817    .map_err(|err| {
4818        // The endpoint is not registered, so there is no live control lane to
4819        // ask. That is the module being absent, not slow.
4820        HealthProbeError::lane_dead(format!("failed to begin health.check RPC: {err}"))
4821    })?;
4822    await_health_probe(forwarding, pending, deadline, runtime.health.deadline).await
4823}
4824
4825/// [`probe_module_health`] for one endpoint rather than the id's active one.
4826///
4827/// A swap probes two processes that no by-id lookup reaches: its candidate
4828/// before cutover, and its superseded incumbent (for busy gauges) while the
4829/// incumbent drains. `deadline_cap` bounds the probe the way a drain deadline
4830/// bounds the by-id drain probe.
4831async fn probe_endpoint_health(
4832    endpoint: crate::ModuleEndpointId,
4833    runtime: &SupervisorRuntimeConfig,
4834    deadline_cap: Option<Instant>,
4835) -> Result<HealthReport, HealthProbeError> {
4836    let Some(forwarding) = runtime.forwarding.as_ref() else {
4837        return Err(HealthProbeError::misconfigured(
4838            "supervisor was not configured with a forwarding table",
4839        ));
4840    };
4841    let probe_started_at = Instant::now();
4842    let mut deadline = probe_started_at + runtime.health.deadline;
4843    if let Some(cap) = deadline_cap {
4844        deadline = deadline.min(cap);
4845    }
4846    let pending = forwarding
4847        .begin_endpoint_health_probe_rpc_for(
4848            endpoint,
4849            MODULE_CONTROL_OP_HEALTH_CHECK,
4850            probe_started_at,
4851            deadline,
4852        )
4853        .map_err(|err| {
4854            HealthProbeError::lane_dead(format!("failed to begin health.check RPC: {err}"))
4855        })?;
4856    await_health_probe(forwarding, pending, deadline, runtime.health.deadline).await
4857}
4858
4859/// Send a begun health probe and classify its answer.
4860async fn await_health_probe(
4861    forwarding: &ForwardingTable,
4862    pending: PendingModuleControlRpc,
4863    deadline: Instant,
4864    probe_budget: Duration,
4865) -> Result<HealthReport, HealthProbeError> {
4866    let PendingModuleControlRpc {
4867        endpoint,
4868        module_sink,
4869        negotiated_ver,
4870        corr,
4871        receiver,
4872    } = pending;
4873    let body = serde_json::to_vec(&ModuleControlRequest::HealthCheck {}).map_err(|err| {
4874        HealthProbeError::misconfigured(format!("failed to encode health.check: {err}"))
4875    })?;
4876    let frame = Frame::build_with_version(
4877        negotiated_ver,
4878        FrameType::Request,
4879        control_flags(),
4880        0,
4881        0,
4882        corr,
4883        body,
4884    )
4885    .map_err(|err| {
4886        HealthProbeError::misconfigured(format!("failed to build health.check frame: {err}"))
4887    })?;
4888
4889    // The enqueue itself must be bounded by the probe deadline: FrameSink.send
4890    // blocks waiting for capacity when the module's egress queue is full, and an
4891    // unbounded await here freezes the whole supervision actor (it stops polling
4892    // Child::wait and supervisor commands), making the module unrecoverable
4893    // in-band. On timeout the probe fails like any transport failure.
4894    match timeout_at(deadline, module_sink.send(frame)).await {
4895        Ok(Ok(())) => {}
4896        Ok(Err(err)) => {
4897            let _ = forwarding.cancel_module_control_rpc(endpoint, corr);
4898            // A closed sink means the module's egress channel is gone -- the
4899            // receiving half is dropped when its connection tears down. Proof.
4900            return Err(HealthProbeError::lane_dead(format!(
4901                "failed to send health.check: {err}"
4902            )));
4903        }
4904        Err(_elapsed) => {
4905            let _ = forwarding.cancel_module_control_rpc(endpoint, corr);
4906            // A full egress queue means the module is not draining its socket, which
4907            // is consistent with a wedged module AND with one whose reader is merely
4908            // starved. Silence, not proof.
4909            return Err(HealthProbeError::no_answer(
4910                "health.check send timed out before enqueue (module egress full)",
4911            ));
4912        }
4913    }
4914
4915    match timeout_at(deadline, receiver).await {
4916        // Each arm records WHAT WAS OBSERVED. Four of them are the module
4917        // demonstrably answering -- rejected, non-health, malformed, wrong op --
4918        // and those prove it is alive even though the probe failed.
4919        Ok(Ok(ModuleControlRpcOutcome::Response(response))) => {
4920            response.health_report().ok_or_else(|| {
4921                HealthProbeError::bad_answer("health.check RPC returned a non-health response")
4922            })
4923        }
4924        Ok(Ok(ModuleControlRpcOutcome::Rejected(body))) => Err(HealthProbeError::bad_answer(
4925            format!("health.check rejected: {}", body.message),
4926        )),
4927        Ok(Ok(ModuleControlRpcOutcome::ModuleGone(message))) => {
4928            Err(HealthProbeError::lane_dead(message))
4929        }
4930        Ok(Ok(ModuleControlRpcOutcome::MalformedResponse(message))) => {
4931            Err(HealthProbeError::bad_answer(message))
4932        }
4933        Ok(Ok(ModuleControlRpcOutcome::UnexpectedOp { expected, actual })) => {
4934            Err(HealthProbeError::bad_answer(format!(
4935                "expected module-control op '{expected}', got '{actual}'"
4936            )))
4937        }
4938        // A reply that crosses the deadline before this waiter observes it is
4939        // still proof of life. The forwarding path records its end-to-end latency
4940        // before delivering this classification.
4941        Ok(Ok(ModuleControlRpcOutcome::DeadlineElapsed)) => Err(HealthProbeError::bad_answer(
4942            "module answered health.check after its daemon deadline",
4943        )),
4944        Ok(Err(_)) => Err(HealthProbeError::misconfigured(
4945            "health.check waiter was canceled before the module responded",
4946        )),
4947        Err(_) => {
4948            let _ = forwarding.tombstone_health_probe_rpc(endpoint, corr);
4949            Err(HealthProbeError::no_answer(format!(
4950                "module did not answer health.check within {probe_budget:?}"
4951            )))
4952        }
4953    }
4954}
4955
4956#[allow(clippy::too_many_arguments)]
4957async fn handle_health_report(
4958    spec: &ModuleSpec,
4959    runtime: &SupervisorRuntimeConfig,
4960    registry: &Registry,
4961    process_liveness: &SupervisorProcessLiveness,
4962    snapshot: &SharedSnapshot,
4963    child: &mut Option<SupervisedChild>,
4964    report: HealthReport,
4965    now_ms: u64,
4966) {
4967    let status = supervisor_health_status(report.status);
4968    let detail = report.detail.clone();
4969    let metrics = truncate_health_metrics(report.metrics);
4970    let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4971        state.health.status = status;
4972        state.health.last_probe_ms = Some(now_ms);
4973        state.health.detail = detail.clone();
4974        state.health.metrics = metrics.clone();
4975        state.health.consecutive_failures = 0;
4976    });
4977
4978    let action = match report.status {
4979        HealthStatus::Ok => return,
4980        HealthStatus::Degraded => runtime.health.on_degraded,
4981        HealthStatus::Failing => runtime.health.on_failing,
4982    };
4983    apply_l3_health_action(
4984        spec,
4985        runtime,
4986        registry,
4987        process_liveness,
4988        snapshot,
4989        child,
4990        status,
4991        detail.as_deref(),
4992        action,
4993        now_ms,
4994    )
4995    .await;
4996}
4997
4998#[allow(clippy::too_many_arguments)]
4999async fn handle_health_probe_failure(
5000    spec: &ModuleSpec,
5001    runtime: &SupervisorRuntimeConfig,
5002    registry: &Registry,
5003    process_liveness: &SupervisorProcessLiveness,
5004    snapshot: &SharedSnapshot,
5005    child: &mut Option<SupervisedChild>,
5006    err: HealthProbeError,
5007    now_ms: u64,
5008) {
5009    let threshold = runtime.health.failure_threshold.max(1);
5010    let mut failures = 0;
5011    // Carry the evidence class into the operator-visible detail. Without it,
5012    // "module did not answer within 5s" and "the control lane is gone" are two
5013    // prose strings in the same field, and the reader has to know the codebase to
5014    // tell which one is proof of anything.
5015    let detail = format!("[{}] {err}", err.label());
5016    let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
5017        // A failed wire probe invalidates the last report, even before the
5018        // restart threshold. HTTP probes already mark failures as Failing.
5019        if state.spawned_protocol.unwrap_or(spec.protocol) == ModuleProtocol::Subc {
5020            state.health.status = SupervisorHealthStatus::Unknown;
5021        }
5022        state.health.last_probe_ms = Some(now_ms);
5023        state.health.consecutive_failures = state.health.consecutive_failures.saturating_add(1);
5024        state.health.detail = Some(detail.clone());
5025        state.health.metrics = None;
5026        failures = state.health.consecutive_failures;
5027    });
5028
5029    if failures < threshold {
5030        warn!(
5031            module_id = %spec.module_id,
5032            consecutive_failures = failures,
5033            threshold,
5034            evidence = err.label(),
5035            detail = %detail,
5036            "health.check probe failed"
5037        );
5038        return;
5039    }
5040
5041    let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
5042        state.state = ModuleState::Unresponsive;
5043        state.health.status = SupervisorHealthStatus::Unresponsive;
5044    });
5045    // The evidence class is logged at the kill site because this is the line an
5046    // operator reads after an unexplained restart. A streak of `no-answer` under
5047    // machine load is the known false-positive shape; a `lane-dead` is not.
5048    if runtime.health.critical {
5049        error!(
5050            module_id = %spec.module_id,
5051            status = "unresponsive",
5052            evidence = err.label(),
5053            detail = %detail,
5054            "critical module health alert"
5055        );
5056    } else {
5057        warn!(
5058            module_id = %spec.module_id,
5059            status = "unresponsive",
5060            evidence = err.label(),
5061            detail = %detail,
5062            "module health threshold breached"
5063        );
5064    }
5065    if let Err(err) = health_restart_child(
5066        spec,
5067        runtime,
5068        registry,
5069        process_liveness,
5070        snapshot,
5071        child,
5072        SupervisorHealthStatus::Unresponsive,
5073        Some(&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#[allow(clippy::too_many_arguments)]
5083async fn apply_l3_health_action(
5084    spec: &ModuleSpec,
5085    runtime: &SupervisorRuntimeConfig,
5086    registry: &Registry,
5087    process_liveness: &SupervisorProcessLiveness,
5088    snapshot: &SharedSnapshot,
5089    child: &mut Option<SupervisedChild>,
5090    status: SupervisorHealthStatus,
5091    detail: Option<&str>,
5092    action: HealthAction,
5093    now_ms: u64,
5094) {
5095    record_health_action(snapshot, &spec.module_id, action.to_string(), now_ms);
5096    match action {
5097        HealthAction::Report => {
5098            info!(
5099                module_id = %spec.module_id,
5100                status = ?status,
5101                detail,
5102                "module reported non-ok health"
5103            );
5104        }
5105        HealthAction::Alert => {
5106            error!(
5107                module_id = %spec.module_id,
5108                status = ?status,
5109                detail,
5110                "module health alert"
5111            );
5112        }
5113        HealthAction::Restart => {
5114            if let Err(err) = health_restart_child(
5115                spec,
5116                runtime,
5117                registry,
5118                process_liveness,
5119                snapshot,
5120                child,
5121                status,
5122                detail,
5123                now_ms,
5124            )
5125            .await
5126            {
5127                error!(module_id = %spec.module_id, error = %err, "health-triggered restart failed");
5128            }
5129        }
5130    }
5131}
5132
5133#[allow(clippy::too_many_arguments)]
5134async fn health_restart_child(
5135    spec: &ModuleSpec,
5136    runtime: &SupervisorRuntimeConfig,
5137    registry: &Registry,
5138    process_liveness: &SupervisorProcessLiveness,
5139    snapshot: &SharedSnapshot,
5140    child: &mut Option<SupervisedChild>,
5141    status: SupervisorHealthStatus,
5142    detail: Option<&str>,
5143    now_ms: u64,
5144) -> Result<(), SuperviseError> {
5145    let (enabled, schedule) = {
5146        let mut state = lock_snapshot(snapshot)?;
5147        let enabled = state.enabled;
5148        let schedule = if enabled {
5149            state.next_crash_restart(&runtime.restart_policy, Instant::now())
5150        } else {
5151            None
5152        };
5153        (enabled, schedule)
5154    };
5155
5156    if !enabled {
5157        return Err(SuperviseError::Disabled {
5158            module_id: spec.module_id.clone(),
5159        });
5160    }
5161
5162    if schedule.is_none() {
5163        record_health_action(snapshot, &spec.module_id, "failed".to_string(), now_ms);
5164        error!(
5165            module_id = %spec.module_id,
5166            status = ?status,
5167            detail,
5168            max_restarts = runtime.restart_policy.max_restarts,
5169            window_secs = runtime.restart_policy.window.as_secs(),
5170            reason = %runtime.restart_policy.budget_exhausted_detail(),
5171            "health restart budget exhausted; marking module failed"
5172        );
5173        let stop_notice = begin_forwarding_drain_if_configured(
5174            spec,
5175            runtime,
5176            registry,
5177            snapshot,
5178            Some(true),
5179            RouteCloseReason::Disable,
5180        )
5181        .await?;
5182        update_snapshot(snapshot, Some(&spec.module_id), |state| {
5183            state.drain_disposition_detail = Some(runtime.restart_policy.budget_exhausted_detail());
5184        })?;
5185        drain_optional_child(
5186            &spec.module_id,
5187            spec.protocol,
5188            stop_notice,
5189            registry,
5190            runtime.forwarding.as_deref(),
5191            snapshot,
5192            &runtime.terminal_ring,
5193            &runtime.spawn_events,
5194            child,
5195            runtime.drain_timeout,
5196            ModuleState::Failed,
5197            Some(true),
5198        )
5199        .await?;
5200        process_liveness.untrack_if_current(&spec.module_id, snapshot);
5201        return Ok(());
5202    }
5203
5204    let schedule = schedule.expect("a health restart must have a crash-restart schedule");
5205    let mut restart_count = 0;
5206    update_snapshot(snapshot, Some(&spec.module_id), |state| {
5207        restart_count = state.crash_restarts.len();
5208        state.state = ModuleState::Unresponsive;
5209        state.health.status = status;
5210        state.health.last_action = Some(HealthAction::Restart.to_string());
5211        state.health.last_action_ms = Some(now_ms);
5212    })?;
5213    warn!(
5214        module_id = %spec.module_id,
5215        status = ?status,
5216        detail,
5217        restart_count,
5218        restart_in_window = schedule.restart_in_window,
5219        delay_ms = schedule.delay.as_millis() as u64,
5220        "health-triggered module restart"
5221    );
5222
5223    let stop_notice = begin_forwarding_drain_if_configured(
5224        spec,
5225        runtime,
5226        registry,
5227        snapshot,
5228        Some(true),
5229        RouteCloseReason::Restart,
5230    )
5231    .await?;
5232    drain_optional_child(
5233        &spec.module_id,
5234        spec.protocol,
5235        stop_notice,
5236        registry,
5237        runtime.forwarding.as_deref(),
5238        snapshot,
5239        &runtime.terminal_ring,
5240        &runtime.spawn_events,
5241        child,
5242        runtime.drain_timeout,
5243        ModuleState::Restarting,
5244        Some(true),
5245    )
5246    .await?;
5247    schedule_respawn(
5248        runtime,
5249        snapshot,
5250        &spec.module_id,
5251        schedule.delay,
5252        RespawnKind::Spawn,
5253    )
5254}
5255
5256fn cancel_deferred_reload(runtime: &SupervisorRuntimeConfig, module_id: &str, reason: &str) {
5257    if let Some(reply) = runtime
5258        .deferred_reload_reply
5259        .lock()
5260        .unwrap_or_else(|p| p.into_inner())
5261        .take()
5262    {
5263        let _ = reply.send(Err(SuperviseError::ReloadFailed {
5264            module_id: module_id.to_string(),
5265            reason: reason.to_string(),
5266        }));
5267    }
5268}
5269
5270fn schedule_respawn(
5271    runtime: &SupervisorRuntimeConfig,
5272    snapshot: &SharedSnapshot,
5273    module_id: &str,
5274    delay: Duration,
5275    kind: RespawnKind,
5276) -> Result<(), SuperviseError> {
5277    cancel_deferred_reload(runtime, module_id, "respawn superseded by another restart");
5278    update_snapshot(snapshot, Some(module_id), |state| {
5279        state.respawn_pending = true
5280    })?;
5281    *runtime
5282        .scheduled_respawn
5283        .lock()
5284        .unwrap_or_else(|p| p.into_inner()) = Some(PendingRespawn {
5285        deadline: Instant::now() + delay,
5286        kind,
5287    });
5288    Ok(())
5289}
5290
5291fn record_health_action(snapshot: &SharedSnapshot, module_id: &str, action: String, now_ms: u64) {
5292    let _ = update_snapshot(snapshot, Some(module_id), |state| {
5293        state.health.last_action = Some(action);
5294        state.health.last_action_ms = Some(now_ms);
5295    });
5296}
5297
5298fn supervisor_health_status(status: HealthStatus) -> SupervisorHealthStatus {
5299    match status {
5300        HealthStatus::Ok => SupervisorHealthStatus::Ok,
5301        HealthStatus::Degraded => SupervisorHealthStatus::Degraded,
5302        HealthStatus::Failing => SupervisorHealthStatus::Failing,
5303    }
5304}
5305
5306/// Caps the metrics blob stored in the cached supervisor snapshot, which is
5307/// returned to every `supervisor.list` and `supervisor.health` caller.
5308///
5309/// This cap is deliberately NOT applied on the one-shot `supervisor.health_probe`
5310/// path: that request exists to return a module's complete metrics object, and
5311/// `ck health <module-id>` documents it as the way to see what the cached view
5312/// truncates. The asymmetry is the feature.
5313///
5314/// So a new caller must decide which side it is on rather than assume the cap is
5315/// universal. Reaching for it on a fresh-probe path would silently reintroduce
5316/// the truncation that path exists to avoid.
5317fn truncate_health_metrics(metrics: Option<Value>) -> Option<Value> {
5318    let metrics = metrics?;
5319    match serde_json::to_vec(&metrics) {
5320        Ok(encoded) if encoded.len() > MAX_HEALTH_METRICS_BYTES => Some(serde_json::json!({
5321            "truncated": true,
5322            "original_bytes": encoded.len(),
5323        })),
5324        Ok(_) | Err(_) => Some(metrics),
5325    }
5326}
5327
5328/// Spread health probes so a fleet-wide restart does not converge them.
5329///
5330/// The delay is derived from the module id and probe index rather than a random
5331/// source, so it is deterministic per module: a module keeps its own offset
5332/// across daemon restarts instead of re-rolling into a collision.
5333fn jittered_health_delay(module_id: &str, probe_index: u64, cadence: Duration) -> Duration {
5334    if cadence.is_zero() {
5335        return Duration::ZERO;
5336    }
5337    let cadence_ms = cadence.as_millis() as u64;
5338    // This early return is REDUNDANT, deliberately, and a mutation run will show
5339    // it surviving removal. Recording why here so the next person to notice does
5340    // not have to re-derive it:
5341    //
5342    // - It is unreachable in practice. `positive_millis` in daemon_config rejects
5343    //   a zero cadence and builds the Duration from whole milliseconds, so a
5344    //   sub-millisecond cadence cannot come from config.
5345    // - Even if reached it changes no answer. The `.max(1)` below makes the span
5346    //   1, and `hash % 1` is 0, so the fall-through returns `cadence` unchanged
5347    //   -- exactly what this returns.
5348    //
5349    // Kept as a guard against a future widening of the config parser (accepting
5350    // microseconds, say), which would make the sub-millisecond case reachable.
5351    // The `.max(1)` is the load-bearing half TODAY: remove it and the modulo
5352    // divides by zero. Remove this and nothing changes.
5353    if cadence_ms == 0 {
5354        return cadence;
5355    }
5356    // Note that this never returns less than one cadence, including for the FIRST
5357    // probe. So a freshly registered module reports health `unknown` for a full
5358    // cadence plus jitter -- 30-33s at the default -- no matter how quickly it is
5359    // ready to answer.
5360    //
5361    // That is a property of the supervisor's schedule, not of any module: an
5362    // operator watching a restart sees `unknown` and cannot tell it from a module
5363    // that is slow to warm. Measured on two unrelated modules, both flipping to
5364    // `ok` between 22s and 32s after restart.
5365    //
5366    // Left as-is because spreading the first probe is what keeps a fleet-wide
5367    // restart from firing fourteen simultaneous probes into a cold machine. The
5368    // alternative -- probe at t+0 and jitter only from the second onward -- trades
5369    // that thundering herd for a faster first reading.
5370    let jitter_span = (cadence_ms / 10).max(1);
5371    let hash = module_id.as_bytes().iter().fold(
5372        probe_index.wrapping_mul(0x9E37_79B9_7F4A_7C15),
5373        |acc, byte| {
5374            acc.wrapping_mul(1099511628211)
5375                .wrapping_add(u64::from(*byte))
5376        },
5377    );
5378    cadence + Duration::from_millis(hash % jitter_span)
5379}
5380
5381#[cfg(test)]
5382mod tests {
5383    use super::*;
5384
5385    #[test]
5386    fn readding_a_module_clears_its_rescan_removal_tombstone() {
5387        let handle = SupervisorHandle::new();
5388        let module_id = "readded-tombstone";
5389        handle.record_rescan_removal(module_id);
5390        assert!(handle.removal_tombstone_age_ms(module_id).is_some());
5391
5392        handle.apply_identity_configuration(&ModuleSpec {
5393            module_id: module_id.to_string(),
5394            program: PathBuf::from("/test/module"),
5395            args: Vec::new(),
5396            env: Vec::new(),
5397            reserved: false,
5398            reserved_prefixes: Vec::new(),
5399            protocol: ModuleProtocol::Subc,
5400            overlap: Default::default(),
5401        });
5402
5403        assert!(
5404            handle.removal_tombstone_age_ms(module_id).is_none(),
5405            "a re-added module must not retain a stale removal tombstone"
5406        );
5407    }
5408
5409    /// What one module's owner looked like from the control plane at the
5410    /// instant after its first process was spawned.
5411    #[derive(Debug, PartialEq, Eq)]
5412    struct OwnerInSpawnWindow {
5413        module_id: String,
5414        configured: bool,
5415        on_roster: bool,
5416        admission_refusal: Option<&'static str>,
5417    }
5418
5419    /// A supervised module's process can connect, register, sync its scopes
5420    /// and describe them as soon as it is spawned. A plain `spawn` does not put
5421    /// the module on the roster until afterward; configured supervision does
5422    /// so before spawning. In both cases the owner must
5423    /// already read as configured, so a scoped `route.open` against it is
5424    /// refused as retryable `scope_not_synced` and not as terminal
5425    /// `scope_not_live` ("will never sync").
5426    ///
5427    /// The hook runs just after each initial spawn attempt, before returning to
5428    /// the caller, so no race with a real child's registration is needed.
5429    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5430    async fn a_new_module_is_configured_before_its_first_process_can_register() {
5431        use crate::scopes::ScopeTable;
5432        use subc_protocol::{error_codes, scope::ScopeSelector, Principal};
5433
5434        let dir = cortexkit_test_support::ScratchDir::new("configured-before-spawn");
5435        let stub = |module_id: &str, program: PathBuf| ModuleSpec {
5436            module_id: module_id.to_string(),
5437            program,
5438            args: Vec::new(),
5439            env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
5440                .into_iter()
5441                .map(|key| (key.to_string(), dir.path().display().to_string()))
5442                .chain([("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string())])
5443                .collect(),
5444            reserved: false,
5445            reserved_prefixes: Vec::new(),
5446            protocol: ModuleProtocol::Subc,
5447            overlap: Default::default(),
5448        };
5449        let live = super::terminal_history_tests::fake_aft_stub_path();
5450        let missing = dir.path().join("definitely-missing-module");
5451
5452        let handle = SupervisorHandle::new();
5453        let mut supervisor =
5454            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5455                .with_handle(handle.clone());
5456        let observed = Arc::new(Mutex::new(Vec::<OwnerInSpawnWindow>::new()));
5457        let hook_handle = handle.clone();
5458        let hook_observed = Arc::clone(&observed);
5459        supervisor.test_after_first_spawn = AfterFirstSpawnHook(Some(Arc::new(move |module_id| {
5460            // Exactly what the control plane computes for a scoped route.open
5461            // naming this module as the owner of a scope it has not synced.
5462            let configured = hook_handle.is_configured(module_id);
5463            let selector = ScopeSelector {
5464                owner: Principal::Reserved {
5465                    module_id: module_id.to_string(),
5466                },
5467                scope_ref: "s".to_string(),
5468                scope_epoch: Some(1),
5469            };
5470            let carrier = Principal::Reserved {
5471                module_id: "carrier".to_string(),
5472            };
5473            let admission_refusal = match ScopeTable::new(Vec::<String>::new())
5474                .admit(&carrier, module_id, &selector, configured)
5475            {
5476                Ok(_) => None,
5477                Err(refusal) => Some(refusal.code),
5478            };
5479            hook_observed.lock().unwrap().push(OwnerInSpawnWindow {
5480                module_id: module_id.to_string(),
5481                configured,
5482                on_roster: hook_handle.get(module_id).is_some(),
5483                admission_refusal,
5484            });
5485        })));
5486
5487        let plain = supervisor.spawn(stub("plain", live.clone())).unwrap();
5488        let configured = supervisor
5489            .supervise_configured(stub("configured", live.clone()), true)
5490            .unwrap();
5491        let with_health = supervisor
5492            .supervise_configured_with_health(
5493                stub("with-health", live.clone()),
5494                true,
5495                HealthConfig::default(),
5496                None,
5497                RestartPolicy::default(),
5498            )
5499            .unwrap();
5500        // The failed-spawn path still puts the module on the roster (as
5501        // failed), so it is configured throughout.
5502        let failed = supervisor
5503            .supervise_configured_with_health(
5504                stub("failed-spawn", missing.clone()),
5505                true,
5506                HealthConfig::default(),
5507                None,
5508                RestartPolicy::default(),
5509            )
5510            .unwrap();
5511        // A failed plain `spawn` puts nothing on the roster, so its mark is
5512        // taken back once the spawn has failed.
5513        assert!(supervisor.spawn(stub("spawn-error", missing)).is_err());
5514
5515        let expected = [
5516            "plain",
5517            "configured",
5518            "with-health",
5519            "failed-spawn",
5520            "spawn-error",
5521        ]
5522        .into_iter()
5523        .map(|module_id| OwnerInSpawnWindow {
5524            module_id: module_id.to_string(),
5525            configured: true,
5526            on_roster: !matches!(module_id, "plain" | "spawn-error"),
5527            admission_refusal: Some(error_codes::SCOPE_NOT_SYNCED),
5528        })
5529        .collect::<Vec<_>>();
5530        assert_eq!(*observed.lock().unwrap(), expected);
5531
5532        for module_id in ["plain", "configured", "with-health", "failed-spawn"] {
5533            assert!(
5534                handle.get(module_id).is_some(),
5535                "{module_id} is on the roster"
5536            );
5537            assert!(
5538                handle.is_configured(module_id),
5539                "{module_id} stays configured"
5540            );
5541        }
5542        assert!(handle.get("spawn-error").is_none());
5543        assert!(
5544            !handle.is_configured("spawn-error"),
5545            "a plain spawn that failed must not leave its module marked configured"
5546        );
5547
5548        // Leaving the roster clears the mark with it.
5549        handle.retire("failed-spawn");
5550        assert!(!handle.is_configured("failed-spawn"));
5551
5552        for module in [plain, configured, with_health] {
5553            module.stop().await.unwrap();
5554        }
5555        drop(failed);
5556    }
5557
5558    fn stale_process_snapshot(state: ModuleState, enabled: bool) -> SharedSnapshot {
5559        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(state, enabled)));
5560        update_snapshot(&snapshot, Some("stale-process-facts"), |snapshot| {
5561            snapshot.process_alive = true;
5562            snapshot.pid = Some(41);
5563            snapshot.spawned_at_ms = Some(42);
5564            snapshot.spawned_from = Some(PathBuf::from("/spawned/module"));
5565            snapshot.spawned_file_identity = Some(SpawnedFileIdentity {
5566                device: 43,
5567                inode: 44,
5568                #[cfg(windows)]
5569                windows_image: None,
5570            });
5571        })
5572        .unwrap();
5573        snapshot
5574    }
5575
5576    fn assert_snapshot_process_facts_cleared(snapshot: &SharedSnapshot) {
5577        let snapshot = lock_snapshot(snapshot).unwrap();
5578        assert!(!snapshot.process_alive);
5579        assert_eq!(snapshot.pid, None);
5580        assert_eq!(snapshot.spawned_at_ms, None);
5581        assert_eq!(snapshot.spawned_from, None);
5582        assert_eq!(snapshot.spawned_file_identity, None);
5583    }
5584
5585    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5586    async fn failed_enable_spawn_clears_preexisting_current_process_facts() {
5587        let supervisor =
5588            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5589        let mut runtime = supervisor.runtime_config();
5590        runtime.test_seed_stale_facts_before_enable_spawn = true;
5591        let snapshot = stale_process_snapshot(ModuleState::Disabled, false);
5592        let mut child = None;
5593        let spec = ModuleSpec {
5594            module_id: "failed-enable-clears-facts".to_string(),
5595            program: PathBuf::from("/definitely/missing/failed-enable-module"),
5596            args: Vec::new(),
5597            env: Vec::new(),
5598            reserved: false,
5599            reserved_prefixes: Vec::new(),
5600            protocol: ModuleProtocol::Subc,
5601            overlap: Default::default(),
5602        };
5603
5604        let result = set_child_enabled(
5605            &spec,
5606            &runtime,
5607            &supervisor.registry,
5608            &supervisor.process_liveness,
5609            &snapshot,
5610            &mut child,
5611            true,
5612        )
5613        .await;
5614
5615        assert!(matches!(result, Err(SuperviseError::Spawn { .. })));
5616        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
5617        assert_snapshot_process_facts_cleared(&snapshot);
5618    }
5619
5620    #[tokio::test]
5621    async fn start_revives_stranded_restarting_but_not_pending_backoff() {
5622        let supervisor =
5623            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5624        let runtime = supervisor.runtime_config();
5625        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(
5626            ModuleState::Restarting,
5627            true,
5628        )));
5629        let spec = ModuleSpec {
5630            module_id: "start-stranded-restarting".to_string(),
5631            program: super::terminal_history_tests::fake_aft_stub_path(),
5632            args: Vec::new(),
5633            env: vec![("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string())],
5634            reserved: false,
5635            reserved_prefixes: Vec::new(),
5636            protocol: ModuleProtocol::None,
5637            overlap: Default::default(),
5638        };
5639        let mut child = None;
5640        lock_snapshot(&snapshot).unwrap().respawn_pending = true;
5641        assert!(!super::set_child_enabled(
5642            &spec,
5643            &runtime,
5644            &Registry::default(),
5645            &supervisor.process_liveness,
5646            &snapshot,
5647            &mut child,
5648            true
5649        )
5650        .await
5651        .unwrap());
5652        assert!(child.is_none());
5653        lock_snapshot(&snapshot).unwrap().respawn_pending = false;
5654        assert!(super::set_child_enabled(
5655            &spec,
5656            &runtime,
5657            &Registry::default(),
5658            &supervisor.process_liveness,
5659            &snapshot,
5660            &mut child,
5661            true
5662        )
5663        .await
5664        .unwrap());
5665        assert_eq!(
5666            lock_snapshot(&snapshot).unwrap().state,
5667            ModuleState::Running
5668        );
5669        let mut child = child.unwrap();
5670        child.start_kill().unwrap();
5671        child.wait().await.unwrap();
5672    }
5673
5674    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5675    async fn failed_reload_spawn_clears_current_process_facts() {
5676        let supervisor =
5677            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5678        let mut runtime = supervisor.runtime_config();
5679        runtime.restart_policy = RestartPolicy::new(0, Duration::ZERO);
5680        let snapshot = stale_process_snapshot(ModuleState::Running, true);
5681        let mut child = None;
5682        let spec = ModuleSpec {
5683            module_id: "failed-reload-clears-facts".to_string(),
5684            program: PathBuf::from("/unused/failed-reload-module"),
5685            args: Vec::new(),
5686            env: Vec::new(),
5687            reserved: false,
5688            reserved_prefixes: Vec::new(),
5689            protocol: ModuleProtocol::Subc,
5690            overlap: Default::default(),
5691        };
5692
5693        let result = handle_reload_spawn_failure(
5694            &spec,
5695            &runtime,
5696            &supervisor.process_liveness,
5697            &snapshot,
5698            &mut child,
5699            "forced reload spawn failure".to_string(),
5700        )
5701        .await;
5702
5703        assert!(matches!(result, Err(SuperviseError::ReloadFailed { .. })));
5704        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
5705        assert_snapshot_process_facts_cleared(&snapshot);
5706    }
5707
5708    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5709    async fn dropping_a_module_with_an_active_monitor_clears_current_process_facts() {
5710        let supervisor =
5711            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5712        let snapshot = stale_process_snapshot(ModuleState::Running, true);
5713        let module = supervisor.supervised_module(
5714            ModuleSpec {
5715                module_id: "drop-clears-facts".to_string(),
5716                program: PathBuf::from("/unused/drop-module"),
5717                args: Vec::new(),
5718                env: Vec::new(),
5719                reserved: false,
5720                reserved_prefixes: Vec::new(),
5721                protocol: ModuleProtocol::Subc,
5722                overlap: Default::default(),
5723            },
5724            supervisor.runtime_config(),
5725            Arc::clone(&snapshot),
5726            None,
5727        );
5728        assert!(!module
5729            .inner
5730            .monitor
5731            .lock()
5732            .unwrap()
5733            .as_ref()
5734            .unwrap()
5735            .is_finished());
5736
5737        drop(module);
5738
5739        assert_eq!(
5740            lock_snapshot(&snapshot).unwrap().state,
5741            ModuleState::Stopped
5742        );
5743        assert_snapshot_process_facts_cleared(&snapshot);
5744    }
5745
5746    #[cfg(unix)]
5747    #[tokio::test]
5748    async fn rescan_preserves_running_protocol_until_respawn() {
5749        let dir = cortexkit_test_support::ScratchDir::new("rescan-protocol");
5750        let initial = ModuleSpec {
5751            module_id: "rescan-protocol".into(),
5752            program: PathBuf::from("/bin/sleep"),
5753            args: vec!["60".into()],
5754            env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
5755                .into_iter()
5756                .map(|key| (key.into(), dir.path().to_string_lossy().into_owned()))
5757                .collect(),
5758            reserved: false,
5759            reserved_prefixes: vec![],
5760            protocol: ModuleProtocol::None,
5761            overlap: Default::default(),
5762        };
5763        let supervisor =
5764            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5765        let module = supervisor.spawn(initial.clone()).unwrap();
5766        assert!(module.status().unwrap().live);
5767        let mut next = initial;
5768        next.protocol = ModuleProtocol::Subc;
5769        module
5770            .update_configuration(next.clone(), HealthConfig::default(), None)
5771            .await
5772            .unwrap();
5773        assert!(
5774            module.status().unwrap().live,
5775            "rescan must not require HELLO from the old non-wire process"
5776        );
5777        let runtime = supervisor.runtime_config();
5778        let action = on_child_exit(
5779            &next,
5780            RestartPolicy::default(),
5781            &supervisor.registry,
5782            &module.inner.snapshot,
5783            &runtime.terminal_ring,
5784            &runtime.spawn_events,
5785            &runtime.child_roster,
5786            ExitReport {
5787                kind: ExitKind::Clean,
5788                code: Some(0),
5789                signal: None,
5790                at_ms: unix_ms_now(),
5791            },
5792        )
5793        .await;
5794        assert!(
5795            matches!(action, NextAction::Restart { .. }),
5796            "the old non-wire process's clean exit must restart"
5797        );
5798        module.drain().await.unwrap();
5799    }
5800
5801    #[cfg(unix)]
5802    #[tokio::test]
5803    async fn orphan_identity_matches_path_names_and_shebang_interpreters() {
5804        use std::os::unix::fs::PermissionsExt;
5805        let dir = cortexkit_test_support::ScratchDir::new("spawn-image-identity");
5806        let script = dir.join("module.sh");
5807        std::fs::write(&script, "#!/bin/sh\nwhile :; do sleep 0.1; done\n").unwrap();
5808        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700)).unwrap();
5809        let record_path = dir.join("live-children.json");
5810        let supervisor =
5811            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5812                .with_live_children_record(&record_path);
5813        for (program, args) in [
5814            (PathBuf::from("sleep"), vec!["60".into()]),
5815            (script, vec![]),
5816        ] {
5817            let spec = ModuleSpec {
5818                module_id: "image-identity".into(),
5819                program: program.clone(),
5820                args,
5821                env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
5822                    .into_iter()
5823                    .map(|key| (key.into(), dir.to_string_lossy().into_owned()))
5824                    .collect(),
5825                reserved: false,
5826                reserved_prefixes: vec![],
5827                protocol: ModuleProtocol::None,
5828                overlap: Default::default(),
5829            };
5830            let module = supervisor.spawn(spec).unwrap();
5831            #[cfg(target_os = "macos")]
5832            {
5833                // SETEXEC confirmation is asynchronous; the orphan record must
5834                // identify the final image, never the intermediate trampoline.
5835                let deadline = Instant::now() + Duration::from_secs(5);
5836                while crate::live_children::read_record(&record_path)
5837                    .unwrap()
5838                    .iter()
5839                    .all(|entry| entry.executable.is_none())
5840                {
5841                    assert!(Instant::now() < deadline, "module image was not confirmed");
5842                    tokio::time::sleep(Duration::from_millis(5)).await;
5843                }
5844            }
5845            let entry = crate::live_children::read_record(&record_path)
5846                .unwrap()
5847                .pop()
5848                .unwrap();
5849            let observed = subc_os::Process::open(entry.pid)
5850                .unwrap()
5851                .unwrap()
5852                .observe()
5853                .unwrap();
5854            let verdict = crate::live_children::identity_verdict(&entry, entry.pid, &observed);
5855            module.drain().await.unwrap();
5856            assert_eq!(
5857                verdict,
5858                crate::live_children::IdentityVerdict::Matches,
5859                "program {program:?}: recorded {entry:?}, observed {observed:?}"
5860            );
5861        }
5862    }
5863
5864    #[cfg(unix)]
5865    fn http_fixture(
5866        dir: &std::path::Path,
5867        url: &str,
5868        threshold: u32,
5869    ) -> crate::daemon_config::ConfiguredModule {
5870        let path = dir.join("subc.jsonc");
5871        std::fs::write(&path, serde_json::json!({"version": 1, "modules": {"http-process": {
5872            "program": "/bin/sleep", "args": ["60"], "protocol": "none",
5873            "env": {"XDG_DATA_HOME":dir,"XDG_RUNTIME_DIR":dir,"XDG_CONFIG_HOME":dir},
5874            "health": {"http":url,"cadence_ms":20,"deadline_ms":200,"failure_threshold":threshold},
5875            "restart": {"backoff_ms":1,"max_backoff_ms":1}, "drain_timeout_ms":10
5876        }}}).to_string()).unwrap();
5877        crate::daemon_config::load(&path)
5878            .unwrap()
5879            .unwrap()
5880            .modules
5881            .pop()
5882            .unwrap()
5883    }
5884
5885    #[cfg(unix)]
5886    async fn wait_http_health(module: &SupervisedModule, status: SupervisorHealthStatus) {
5887        timeout(Duration::from_secs(5), async {
5888            loop {
5889                if module.status().unwrap().health.status == status {
5890                    break;
5891                }
5892                sleep(Duration::from_millis(5)).await;
5893            }
5894        })
5895        .await
5896        .unwrap_or_else(|_| {
5897            panic!(
5898                "expected {status:?}, got {:?}",
5899                module.status().unwrap().health
5900            )
5901        });
5902    }
5903
5904    #[cfg(unix)]
5905    #[tokio::test]
5906    async fn http_health_status_flips_ok_failing_ok() {
5907        use tokio::io::{AsyncReadExt, AsyncWriteExt};
5908        let dir = cortexkit_test_support::ScratchDir::new("http-status-flips");
5909        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5910        let status = Arc::new(std::sync::atomic::AtomicU16::new(200));
5911        let serving_status = status.clone();
5912        let url = format!("http://{}/healthz", listener.local_addr().unwrap());
5913        let server = tokio::spawn(async move {
5914            loop {
5915                let (mut stream, _) = listener.accept().await.unwrap();
5916                let mut request = [0u8; 2048];
5917                let count = stream.read(&mut request).await.unwrap();
5918                assert!(count > 0, "a probe must send an HTTP request");
5919                let code = serving_status.load(std::sync::atomic::Ordering::SeqCst);
5920                let body = if code == 200 {
5921                    "ready"
5922                } else {
5923                    "{\"status\":\"unavailable\",\"error\":\"scratch failure\"}"
5924                };
5925                let response = format!("HTTP/1.1 {code} scratch\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
5926                let _ = stream.write_all(response.as_bytes()).await;
5927            }
5928        });
5929        let configured = http_fixture(&dir, &url, 1000);
5930        let module =
5931            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5932                .supervise_configured_with_health(
5933                    configured.module_spec(),
5934                    true,
5935                    configured.health,
5936                    configured.drain_timeout_ms,
5937                    configured.restart,
5938                )
5939                .unwrap();
5940        wait_http_health(&module, SupervisorHealthStatus::Ok).await;
5941        status.store(503, std::sync::atomic::Ordering::SeqCst);
5942        wait_http_health(&module, SupervisorHealthStatus::Failing).await;
5943        assert!(module
5944            .status()
5945            .unwrap()
5946            .health
5947            .detail
5948            .unwrap()
5949            .contains("scratch failure"));
5950        status.store(200, std::sync::atomic::Ordering::SeqCst);
5951        wait_http_health(&module, SupervisorHealthStatus::Ok).await;
5952        assert_eq!(module.status().unwrap().health.consecutive_failures, 0);
5953        let before = module.status().unwrap();
5954        let (spec, mut health) = module.configuration().unwrap();
5955        health.http = None;
5956        module
5957            .update_configuration(spec.clone(), health.clone(), Some(10))
5958            .await
5959            .unwrap();
5960        wait_http_health(&module, SupervisorHealthStatus::Unknown).await;
5961        health.http = Some(url);
5962        module
5963            .update_configuration(spec, health, Some(10))
5964            .await
5965            .unwrap();
5966        wait_http_health(&module, SupervisorHealthStatus::Ok).await;
5967        assert_eq!(
5968            module.status().unwrap().pid,
5969            before.pid,
5970            "changing a probe must apply live, not restart its process"
5971        );
5972        let (spec, mut health) = module.configuration().unwrap();
5973        health.failure_threshold = 2;
5974        module
5975            .update_configuration(spec, health, Some(10))
5976            .await
5977            .unwrap();
5978        status.store(503, std::sync::atomic::Ordering::SeqCst);
5979        timeout(Duration::from_secs(5), async {
5980            while module.status().unwrap().spawn_generation == before.spawn_generation {
5981                sleep(Duration::from_millis(5)).await;
5982            }
5983        })
5984        .await
5985        .expect("sustained HTTP 503 responses must trigger the health restart policy");
5986        module.drain().await.unwrap();
5987        server.abort();
5988    }
5989
5990    #[cfg(unix)]
5991    #[tokio::test]
5992    async fn http_health_no_listener_restarts_after_consecutive_failures() {
5993        let dir = cortexkit_test_support::ScratchDir::new("http-refused");
5994        let unused = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5995        let url = format!("http://{}/healthz", unused.local_addr().unwrap());
5996        drop(unused);
5997        let configured = http_fixture(&dir, &url, 2);
5998        let module =
5999            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
6000                .supervise_configured_with_health(
6001                    configured.module_spec(),
6002                    true,
6003                    configured.health,
6004                    configured.drain_timeout_ms,
6005                    configured.restart,
6006                )
6007                .unwrap();
6008        let before = module.status().unwrap().spawn_generation;
6009        timeout(Duration::from_secs(5), async {
6010            loop {
6011                let status = module.status().unwrap();
6012                if status.spawn_generation > before {
6013                    assert!(status.lifetime_restarts > 0);
6014                    break;
6015                }
6016                sleep(Duration::from_millis(5)).await;
6017            }
6018        })
6019        .await
6020        .expect("sustained HTTP refusal must trigger the health restart policy");
6021        module.drain().await.unwrap();
6022    }
6023
6024    #[cfg(unix)]
6025    #[tokio::test]
6026    async fn http_health_timeout_honours_deadline() {
6027        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6028        let url = format!("http://{}/healthz", listener.local_addr().unwrap());
6029        let server = tokio::spawn(async move {
6030            let _held = listener.accept().await.unwrap();
6031            std::future::pending::<()>().await;
6032        });
6033        let error = timeout(
6034            Duration::from_secs(1),
6035            probe_http_health(&url, Duration::from_millis(10)),
6036        )
6037        .await
6038        .expect("the probe must enforce its own deadline")
6039        .unwrap_err();
6040        server.abort();
6041        assert!(error.to_string().contains("timed out"));
6042    }
6043
6044    #[cfg(unix)]
6045    #[tokio::test]
6046    async fn http_health_localhost_reaches_an_ipv6_only_listener() {
6047        use tokio::io::{AsyncReadExt, AsyncWriteExt};
6048        let listener = tokio::net::TcpListener::bind("[::1]:0").await.unwrap();
6049        let url = format!(
6050            "http://localhost:{}/healthz",
6051            listener.local_addr().unwrap().port()
6052        );
6053        let server = tokio::spawn(async move {
6054            let (mut stream, _) = listener.accept().await.unwrap();
6055            let mut request = [0u8; 2048];
6056            assert!(stream.read(&mut request).await.unwrap() > 0);
6057            stream
6058                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
6059                .await
6060                .unwrap();
6061        });
6062        // The deadline only bounds a hang. A probe that never tried the IPv6
6063        // address would be refused on 127.0.0.1 and fail at once, so a longer
6064        // deadline does not weaken the assertion; one second timed out under a
6065        // loaded parallel test run.
6066        assert_eq!(
6067            probe_http_health(&url, Duration::from_secs(10))
6068                .await
6069                .unwrap()
6070                .status,
6071            HealthStatus::Ok
6072        );
6073        server.await.unwrap();
6074    }
6075
6076    #[cfg(unix)]
6077    #[tokio::test]
6078    async fn http_health_timeout_keeps_partial_status_and_body() {
6079        use tokio::io::{AsyncReadExt, AsyncWriteExt};
6080        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6081        let url = format!("http://{}/healthz", listener.local_addr().unwrap());
6082        let server = tokio::spawn(async move {
6083            let (mut stream, _) = listener.accept().await.unwrap();
6084            let mut request = [0u8; 2048];
6085            assert!(stream.read(&mut request).await.unwrap() > 0);
6086            stream
6087                .write_all(
6088                    b"HTTP/1.1 503 Unavailable\r\nContent-Length: 100\r\n\r\npartial diagnostic",
6089                )
6090                .await
6091                .unwrap();
6092            std::future::pending::<()>().await;
6093        });
6094        let error = probe_http_health(&url, Duration::from_secs(1))
6095            .await
6096            .unwrap_err()
6097            .to_string();
6098        server.abort();
6099        assert!(
6100            error.contains("timed out")
6101                && error.contains("503 Unavailable")
6102                && error.contains("partial diagnostic"),
6103            "{error}"
6104        );
6105    }
6106
6107    #[cfg(unix)]
6108    #[tokio::test]
6109    async fn http_health_diagnostic_body_is_bounded_to_200_bytes() {
6110        use tokio::io::{AsyncReadExt, AsyncWriteExt};
6111        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6112        let url = format!("http://{}/healthz", listener.local_addr().unwrap());
6113        let server = tokio::spawn(async move {
6114            let (mut stream, _) = listener.accept().await.unwrap();
6115            let mut request = [0u8; 2048];
6116            assert!(stream.read(&mut request).await.unwrap() > 0);
6117            let body = format!("{}not-in-diagnostic", "x".repeat(400));
6118            let response = format!(
6119                "HTTP/1.1 503 Unavailable\r\nContent-Length: {}\r\n\r\n{body}",
6120                body.len()
6121            );
6122            stream.write_all(response.as_bytes()).await.unwrap();
6123        });
6124        let error = probe_http_health(&url, Duration::from_secs(1))
6125            .await
6126            .unwrap_err()
6127            .to_string();
6128        server.await.unwrap();
6129        assert!(error.ends_with(&"x".repeat(200)), "{error}");
6130        assert_eq!(error.split(": ").last().unwrap().len(), 200);
6131        assert!(!error.contains("not-in-diagnostic"));
6132    }
6133
6134    #[cfg(unix)]
6135    #[tokio::test]
6136    async fn http_health_real_nats_server_monitoring() {
6137        if std::process::Command::new("nats-server")
6138            .arg("--version")
6139            .env("XDG_DATA_HOME", std::env::temp_dir())
6140            .env("XDG_RUNTIME_DIR", std::env::temp_dir())
6141            .env("XDG_CONFIG_HOME", std::env::temp_dir())
6142            .output()
6143            .is_err()
6144        {
6145            eprintln!(
6146                "skipped: http_health_real_nats_server_monitoring (nats-server is not on PATH)"
6147            );
6148            return;
6149        }
6150        let dir = cortexkit_test_support::ScratchDir::new("nats-http-monitoring");
6151        let monitor = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6152        let port = monitor.local_addr().unwrap().port();
6153        let client = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6154        let client_port = client.local_addr().unwrap().port();
6155        let config = dir.join("server.conf");
6156        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();
6157        let mut configured = http_fixture(&dir, &format!("http://127.0.0.1:{port}/healthz"), 1000);
6158        configured.program = PathBuf::from("nats-server");
6159        configured.args = vec!["-c".into(), config.to_string_lossy().into_owned()];
6160        drop(monitor);
6161        drop(client);
6162        let module =
6163            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
6164                .supervise_configured_with_health(
6165                    configured.module_spec(),
6166                    true,
6167                    configured.health,
6168                    configured.drain_timeout_ms,
6169                    configured.restart,
6170                )
6171                .unwrap();
6172        wait_http_health(&module, SupervisorHealthStatus::Ok).await;
6173        module.drain().await.unwrap();
6174    }
6175
6176    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
6177    async fn configuration_update_does_not_replace_captured_running_process_facts() {
6178        let supervisor =
6179            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
6180        let snapshot = stale_process_snapshot(ModuleState::Running, true);
6181        let initial = ModuleSpec {
6182            module_id: "rescan-preserves-spawn-facts".to_string(),
6183            program: PathBuf::from("/spawned/module"),
6184            args: Vec::new(),
6185            env: Vec::new(),
6186            reserved: false,
6187            reserved_prefixes: Vec::new(),
6188            protocol: ModuleProtocol::Subc,
6189            overlap: Default::default(),
6190        };
6191        let module = supervisor.supervised_module(
6192            initial.clone(),
6193            supervisor.runtime_config(),
6194            snapshot,
6195            None,
6196        );
6197        let before = module.status().unwrap();
6198        let mut replacement = initial;
6199        replacement.program = PathBuf::from("/rescanned/replacement-module");
6200
6201        module
6202            .update_configuration(replacement, HealthConfig::default(), None)
6203            .await
6204            .unwrap();
6205
6206        let after = module.status().unwrap();
6207        assert_eq!(after.pid, before.pid);
6208        assert_eq!(after.spawned_at_ms, before.spawned_at_ms);
6209        assert_eq!(after.spawned_from, before.spawned_from);
6210        drop(module);
6211    }
6212}
6213
6214fn unix_ms_now() -> u64 {
6215    SystemTime::now()
6216        .duration_since(UNIX_EPOCH)
6217        .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
6218        .unwrap_or(0)
6219}
6220
6221async fn supervise_loop(
6222    mut spec: ModuleSpec,
6223    mut runtime: SupervisorRuntimeConfig,
6224    registry: Arc<Registry>,
6225    process_liveness: Arc<SupervisorProcessLiveness>,
6226    snapshot: SharedSnapshot,
6227    mut child: Option<SupervisedChild>,
6228    mut commands: mpsc::Receiver<SupervisorCommand>,
6229) {
6230    let mut health_probe = HealthProbeRuntime::default();
6231    // Registry writes (including embedded callers) notify this module only.
6232    // Subscribe before the first refresh; watch retains changes that arrive
6233    // while commands or probes are running. No polling fallback is needed.
6234    let mut registration_changes = match registry.subscribe_module_changes(&spec.module_id) {
6235        Ok(changes) => Some(changes),
6236        Err(err) => {
6237            // A poisoned registry cannot accept further writes, so it cannot
6238            // recover via a timer. Keep exit and command handling alive.
6239            warn!(module_id = %spec.module_id, error = %err, "health prober could not subscribe to registry");
6240            None
6241        }
6242    };
6243    // All restart backoffs run here, including health and operator requests.
6244    // While one is pending the loop serves commands, so disable or drain can
6245    // cancel the replacement without spawning a process just to stop it.
6246    let mut pending_respawn: Option<PendingRespawn> = None;
6247    // Commands a swap handed back to run next (see `swap::SwapEnd`). Served
6248    // before anything else so a stop that interrupted a swap runs at once.
6249    let mut requeued: VecDeque<SupervisorCommand> = VecDeque::new();
6250    #[cfg(target_os = "linux")]
6251    let mut previous_instance_recheck = tokio::time::interval_at(
6252        Instant::now() + Duration::from_secs(5),
6253        Duration::from_secs(5),
6254    );
6255    #[cfg(target_os = "linux")]
6256    previous_instance_recheck.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
6257    loop {
6258        #[cfg(test)]
6259        {
6260            lock_snapshot(&snapshot).unwrap().actor_select = None;
6261        }
6262        #[cfg(target_os = "macos")]
6263        if let Some(active) = child.as_mut() {
6264            active.confirm_privacy_exec().await;
6265        }
6266        if let Some(scheduled) = runtime
6267            .scheduled_respawn
6268            .lock()
6269            .unwrap_or_else(|p| p.into_inner())
6270            .take()
6271        {
6272            pending_respawn = Some(scheduled);
6273        }
6274        if pending_respawn.is_some() && (child.is_some() || !respawn_still_pending(&snapshot)) {
6275            pending_respawn = None;
6276            cancel_deferred_reload(
6277                &runtime,
6278                &spec.module_id,
6279                "respawn cancelled by a supervisor command",
6280            );
6281        }
6282        if child.is_none()
6283            && pending_respawn.is_none()
6284            && lock_snapshot(&snapshot).is_ok_and(|state| state.waiting_for_previous_pid.is_none())
6285        {
6286            cancel_deferred_reload(
6287                &runtime,
6288                &spec.module_id,
6289                "respawn cancelled before a replacement was spawned",
6290            );
6291            let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6292                state.respawn_pending = false;
6293                state.coalesced_restart_pending = false;
6294                if matches!(
6295                    state.state,
6296                    ModuleState::Restarting
6297                        | ModuleState::Starting
6298                        | ModuleState::Draining
6299                        | ModuleState::Unresponsive
6300                ) {
6301                    error!(module_id = %spec.module_id, state = ?state.state,
6302                        "supervision operation ended without a child or pending respawn; marking failed so start can retry");
6303                    state.state = ModuleState::Failed;
6304                    clear_current_process_facts(state);
6305                }
6306            });
6307        }
6308        if let Some(command) = requeued.pop_front() {
6309            if !handle_supervisor_command(
6310                command,
6311                &mut spec,
6312                &mut runtime,
6313                &registry,
6314                &process_liveness,
6315                &snapshot,
6316                &mut child,
6317                &mut commands,
6318                &mut requeued,
6319            )
6320            .await
6321            {
6322                return;
6323            }
6324            if child.is_some() || !respawn_still_pending(&snapshot) {
6325                pending_respawn = None;
6326                let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6327                    state.respawn_pending = false
6328                });
6329            }
6330            continue;
6331        }
6332        if child.is_some() {
6333            #[cfg(test)]
6334            {
6335                lock_snapshot(&snapshot).unwrap().actor_turns += 1;
6336            }
6337            health_probe.refresh_registration(&spec, &runtime, &registry, &snapshot);
6338            let wake_after = health_probe.wake_after();
6339            let probe_sleep = async move {
6340                match wake_after {
6341                    Some(delay) => sleep(delay).await,
6342                    None => std::future::pending().await,
6343                }
6344            };
6345            tokio::pin!(probe_sleep);
6346            // A `protocol: "none"` child (a plain process that never registers
6347            // over the subc wire, such as nats-server) with no HTTP health
6348            // check has nothing to probe, so it waits only for its exit or a
6349            // supervisor command. A rescan that adds an HTTP check is a command.
6350            let wire_child = running_protocol(&spec, &snapshot) == ModuleProtocol::Subc;
6351            #[cfg(test)]
6352            {
6353                let registration_pending = wire_child
6354                    && registration_changes
6355                        .as_ref()
6356                        .is_some_and(|changes| changes.has_changed().unwrap());
6357                let mut state = lock_snapshot(&snapshot).unwrap();
6358                state.actor_select = (!registration_pending).then_some(ActorSelectCheckpoint {
6359                    generation: state.spawn_generation,
6360                    turn: state.actor_turns,
6361                    registered_connection: health_probe.registered_connection,
6362                    next_probe_at: health_probe.next_probe_at,
6363                    wake_after,
6364                });
6365            }
6366            let active_child = child.as_mut().expect("child checked above");
6367            tokio::select! {
6368                wait_result = active_child.wait() => {
6369                    #[cfg(test)]
6370                    { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6371                    // Every arm below that gives up on the CHILD must keep the
6372                    // supervision task itself alive (child = None, loop
6373                    // continues into command-serving mode). Returning here
6374                    // closes the command channel, which makes the module
6375                    // permanently unrestartable in-band: a clean child exit
6376                    // of an enabled module once wedged the fleet this way
6377                    // ('supervisor command channel is closed') and required a
6378                    // full daemon restart to recover.
6379                    let exit_report = match wait_result {
6380                        Ok(status) => classify_reaped_child_exit(&snapshot, active_child, &status),
6381                        Err(err) => {
6382                            active_child.drain_stderr(&spec.module_id).await;
6383                            fail_after_wait_error(
6384                                &spec.module_id,
6385                                &snapshot,
6386                                &runtime.terminal_ring,
6387                                &runtime.spawn_events,
6388                                format!("failed to wait for supervised module: {err}"),
6389                            );
6390                            untrack_if_registration_released(
6391                                &process_liveness,
6392                                &registry,
6393                                &spec.module_id,
6394                                &snapshot,
6395                            );
6396                            error!(module_id = %spec.module_id, error = %err, "failed to wait for supervised module");
6397                            child = None;
6398                            continue;
6399                        }
6400                    };
6401                    active_child.drain_stderr(&spec.module_id).await;
6402
6403                    let next = on_child_exit(
6404                        &spec,
6405                        runtime.restart_policy,
6406                        &registry,
6407                        &snapshot,
6408                        &runtime.terminal_ring,
6409                        &runtime.spawn_events,
6410                        &runtime.child_roster,
6411                        exit_report,
6412                    ).await;
6413                    runtime.start_requester = None;
6414                    runtime.pending_activity_restart = false;
6415                    // The exit is recorded, so a daemon shutdown may stop
6416                    // waiting for this child (see `SupervisedChild::wait`).
6417                    active_child.release_roster();
6418                    match next {
6419                        NextAction::Stop { registration_released } => {
6420                            if registration_released {
6421                                process_liveness.untrack_if_current(&spec.module_id, &snapshot);
6422                            }
6423                            child = None;
6424                        }
6425                        NextAction::Restart { schedule } => {
6426                            let delay = schedule.map_or(
6427                                runtime.restart_policy.delay_for_restart(0),
6428                                |schedule| schedule.delay,
6429                            );
6430                            if let Some(schedule) = schedule {
6431                                log_crash_respawn(&spec.module_id, schedule);
6432                            }
6433                            // The exited child is fully recorded at this point,
6434                            // so release it and count the backoff down in the
6435                            // command-serving branch below rather than sleeping
6436                            // here: commands cannot be received from inside this
6437                            // select arm, and an operator disable or drain that
6438                            // arrives during the backoff must cancel the pending
6439                            // respawn instead of waiting for it to spawn first.
6440                            child = None;
6441                            pending_respawn = Some(PendingRespawn { deadline: Instant::now() + delay, kind: RespawnKind::Spawn });
6442                             let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| state.respawn_pending = true);
6443                        }
6444                    }
6445                }
6446                command = commands.recv() => {
6447                    #[cfg(test)]
6448                    { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6449                    let Some(command) = command else {
6450                        return;
6451                    };
6452                    if !handle_supervisor_command(
6453                        command,
6454                        &mut spec,
6455                        &mut runtime,
6456                        &registry,
6457                        &process_liveness,
6458                        &snapshot,
6459                        &mut child,
6460                        &mut commands,
6461                        &mut requeued,
6462                    ).await {
6463                        return;
6464                    }
6465                }
6466                _ = async {
6467                    match registration_changes.as_mut() {
6468                        Some(changes) => { let _ = changes.changed().await; }
6469                        None => std::future::pending().await,
6470                    }
6471                }, if wire_child => {
6472                    #[cfg(test)]
6473                    { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6474                }
6475                _ = &mut probe_sleep => {
6476                    #[cfg(test)]
6477                    { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6478                    if health_probe.due() {
6479                        run_health_probe_cycle(
6480                            &spec,
6481                            &runtime,
6482                            &registry,
6483                            &process_liveness,
6484                            &snapshot,
6485                            &mut child,
6486                        ).await;
6487                        if child.is_none() {
6488                            runtime.start_requester = None;
6489                            runtime.pending_activity_restart = false;
6490                        }
6491                        if child.is_some() {
6492                            health_probe.schedule_next(&spec, runtime.health.cadence);
6493                        }
6494                    }
6495                }
6496            }
6497        } else if let Some(pending) = pending_respawn {
6498            tokio::select! {
6499                _ = sleep_until(pending.deadline) => {
6500                    pending_respawn = None;
6501                let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| state.respawn_pending = false);
6502                    // A command handled below while the backoff elapsed may
6503                    // have stopped the module; never respawn past an operator's
6504                    // disable or drain.
6505                    if !respawn_still_pending(&snapshot) {
6506                        continue;
6507                    }
6508                    // The daemon began shutting down during the backoff: the
6509                    // spawn would be refused anyway, and refusing it here
6510                    // leaves the module stopped instead of reporting a
6511                    // failed restart.
6512                    if runtime.child_roster.is_closed() {
6513                        let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6514                            state.state = ModuleState::Stopped;
6515                        });
6516                        debug!(module_id = %spec.module_id, "crash respawn cancelled by daemon shutdown");
6517                        continue;
6518                    }
6519                    if let Err(err) = release_dead_registration(
6520                        &registry,
6521                        runtime.forwarding.as_deref(),
6522                        &snapshot,
6523                        &spec.module_id,
6524                    ).await {
6525                        fail_snapshot(&snapshot, Some(&spec.module_id), None);
6526                        error!(module_id = %spec.module_id, error = %err, "registration did not release before restart");
6527                        continue;
6528                    }
6529
6530                    if matches!(pending.kind, RespawnKind::Reload) {
6531                        let reply = runtime.deferred_reload_reply.lock().unwrap_or_else(|p| p.into_inner()).take();
6532                        let result = finish_reload_child(&spec, &runtime, &registry, &process_liveness, &snapshot, &mut child).await;
6533                        if result.is_ok() && runtime.pending_activity_restart {
6534                            runtime.record_activity_transition("subc.module_restarted", &spec.module_id, runtime.start_requester.clone());
6535                        }
6536                        runtime.pending_activity_restart = false;
6537                        if let Some(reply) = reply { let _ = reply.send(result); }
6538                        continue;
6539                    }
6540                    process_liveness.track(spec.module_id.clone(), Arc::clone(&snapshot));
6541                    match spawn_and_mark_running(&spec, &runtime, &snapshot) {
6542                        Ok(next_child) => {
6543                            child = Some(next_child);
6544                            if runtime.pending_activity_restart {
6545                                runtime.record_activity_transition("subc.module_restarted", &spec.module_id, runtime.start_requester.clone());
6546                                runtime.pending_activity_restart = false;
6547                            }
6548                            debug!(module_id = %spec.module_id, "supervised module restarted after crash");
6549                        }
6550                        Err(err) => {
6551                            fail_snapshot(&snapshot, Some(&spec.module_id), None);
6552                            process_liveness.untrack_if_current(&spec.module_id, &snapshot);
6553                            error!(module_id = %spec.module_id, error = %err, "failed to restart supervised module");
6554                        }
6555                    }
6556                }
6557                command = commands.recv() => {
6558                    let Some(command) = command else {
6559                        return;
6560                    };
6561                    if !handle_supervisor_command(
6562                        command,
6563                        &mut spec,
6564                        &mut runtime,
6565                        &registry,
6566                        &process_liveness,
6567                        &snapshot,
6568                        &mut child,
6569                        &mut commands,
6570                        &mut requeued,
6571                    ).await {
6572                        return;
6573                    }
6574                    // Reconcile the pending respawn with what the command did:
6575                    // a start may already have spawned a fresh child,
6576                    // while a disable or drain moved the snapshot out of the
6577                    // state the respawn was counting down from.
6578                    if child.is_some() || !respawn_still_pending(&snapshot) {
6579                        pending_respawn = None;
6580                let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| state.respawn_pending = false);
6581                    }
6582                }
6583            }
6584        } else {
6585            #[cfg(target_os = "linux")]
6586            let command = if lock_snapshot(&snapshot)
6587                .is_ok_and(|state| state.waiting_for_previous_pid.is_some())
6588            {
6589                tokio::select! {
6590                    command = commands.recv() => command,
6591                    _ = previous_instance_recheck.tick() => {
6592                        if runtime.child_roster.previous_instance_pid(&spec.module_id).is_none() {
6593                            let enabled = lock_snapshot(&snapshot).is_ok_and(|state| state.enabled);
6594                            if enabled && !runtime.child_roster.is_closed() {
6595                                process_liveness.track(spec.module_id.clone(), Arc::clone(&snapshot));
6596                                match spawn_and_mark_running(&spec, &runtime, &snapshot) {
6597                                    Ok(next_child) => child = Some(next_child),
6598                                    Err(error) => {
6599                                        fail_snapshot(&snapshot, Some(&spec.module_id), None);
6600                                        error!(module_id = %spec.module_id, %error, "failed to start module after its previous instance exited");
6601                                    }
6602                                }
6603                            } else {
6604                                let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6605                                    state.waiting_for_previous_pid = None;
6606                                });
6607                            }
6608                        }
6609                        continue;
6610                    }
6611                }
6612            } else {
6613                commands.recv().await
6614            };
6615            #[cfg(not(target_os = "linux"))]
6616            let command = commands.recv().await;
6617            let Some(command) = command else {
6618                return;
6619            };
6620            if !handle_supervisor_command(
6621                command,
6622                &mut spec,
6623                &mut runtime,
6624                &registry,
6625                &process_liveness,
6626                &snapshot,
6627                &mut child,
6628                &mut commands,
6629                &mut requeued,
6630            )
6631            .await
6632            {
6633                return;
6634            }
6635        }
6636    }
6637}
6638
6639fn log_crash_respawn(module_id: &str, schedule: CrashRestartSchedule) {
6640    info!(
6641        module_id,
6642        restart_in_window = schedule.restart_in_window,
6643        delay_ms = schedule.delay.as_millis() as u64,
6644        "respawning after crash"
6645    );
6646}
6647
6648/// Whether the respawn a backoff was counting down to is still wanted. A
6649/// disable or drain handled while the backoff elapsed moves the snapshot out
6650/// of `Restarting`, and the operator's stop must win over the pending respawn,
6651/// so every sleep-then-spawn path re-validates against the live snapshot
6652/// instead of assuming the state it left behind still holds.
6653fn respawn_still_pending(snapshot: &SharedSnapshot) -> bool {
6654    matches!(
6655        lock_snapshot(snapshot),
6656        Ok(state) if state.enabled && state.state == ModuleState::Restarting
6657    )
6658}
6659
6660enum NextAction {
6661    Stop {
6662        registration_released: bool,
6663    },
6664    Restart {
6665        schedule: Option<CrashRestartSchedule>,
6666    },
6667}
6668
6669#[allow(clippy::too_many_arguments)]
6670async fn handle_supervisor_command(
6671    command: SupervisorCommand,
6672    spec: &mut ModuleSpec,
6673    runtime: &mut SupervisorRuntimeConfig,
6674    registry: &Arc<Registry>,
6675    process_liveness: &SupervisorProcessLiveness,
6676    snapshot: &SharedSnapshot,
6677    child: &mut Option<SupervisedChild>,
6678    commands: &mut mpsc::Receiver<SupervisorCommand>,
6679    requeued: &mut VecDeque<SupervisorCommand>,
6680) -> bool {
6681    #[cfg(target_os = "linux")]
6682    let command = if let Some(pid) = runtime.child_roster.previous_instance_pid(&spec.module_id) {
6683        match command {
6684            SupervisorCommand::Restart { reply, .. }
6685            | SupervisorCommand::Reload { reply, .. }
6686            | SupervisorCommand::Swap { reply, .. } => {
6687                let _ = reply.send(Err(previous_instance_error(spec, pid)));
6688                return true;
6689            }
6690            SupervisorCommand::SetEnabled {
6691                enabled: true,
6692                reply,
6693                ..
6694            } => {
6695                let _ = reply.send(Err(previous_instance_error(spec, pid)));
6696                return true;
6697            }
6698            other => other,
6699        }
6700    } else {
6701        command
6702    };
6703    match command {
6704        SupervisorCommand::Drain { requester, reply } => {
6705            // A plain stop runs no forwarding drain, so nothing reaches the
6706            // module over its connection before the wait: ask by signal.
6707            let result = drain_optional_child(
6708                &spec.module_id,
6709                spec.protocol,
6710                StopNotice::NotSent,
6711                registry,
6712                runtime.forwarding.as_deref(),
6713                snapshot,
6714                &runtime.terminal_ring,
6715                &runtime.spawn_events,
6716                child,
6717                runtime.drain_timeout,
6718                ModuleState::Stopped,
6719                None,
6720            )
6721            .await;
6722            let registration_released = result.is_ok();
6723            if registration_released {
6724                runtime.record_activity_transition(
6725                    "subc.module_stopped",
6726                    &spec.module_id,
6727                    requester,
6728                );
6729            }
6730            let _ = reply.send(result);
6731            if registration_released {
6732                process_liveness.untrack_if_current(&spec.module_id, snapshot);
6733            }
6734            false
6735        }
6736        SupervisorCommand::Retire { requester, reply } => {
6737            let result = async {
6738                let stop_notice = begin_forwarding_drain_if_configured(
6739                    spec,
6740                    runtime,
6741                    registry,
6742                    snapshot,
6743                    None,
6744                    RouteCloseReason::Disable,
6745                )
6746                .await?;
6747                drain_optional_child(
6748                    &spec.module_id,
6749                    spec.protocol,
6750                    stop_notice,
6751                    registry,
6752                    runtime.forwarding.as_deref(),
6753                    snapshot,
6754                    &runtime.terminal_ring,
6755                    &runtime.spawn_events,
6756                    child,
6757                    runtime.drain_timeout,
6758                    ModuleState::Stopped,
6759                    None,
6760                )
6761                .await
6762            }
6763            .await;
6764            let registration_released = result.is_ok();
6765            if registration_released {
6766                runtime.record_activity_transition(
6767                    "subc.module_stopped",
6768                    &spec.module_id,
6769                    requester,
6770                );
6771            }
6772            let _ = reply.send(result);
6773            if registration_released {
6774                process_liveness.untrack_if_current(&spec.module_id, snapshot);
6775            }
6776            false
6777        }
6778        SupervisorCommand::Restart {
6779            requester,
6780            drain_timeout_ms,
6781            received_at_generation,
6782            queued_at,
6783            reply,
6784        } => {
6785            // Without this line a restart that waited in the queue (behind a
6786            // health probe cycle or another command) was invisible: the log
6787            // showed only the drain timing out, minutes after the operator's call.
6788            info!(
6789                module_id = %spec.module_id,
6790                queued_ms = u64::try_from(queued_at.elapsed().as_millis()).unwrap_or(u64::MAX),
6791                "restart command dequeued"
6792            );
6793            // ACK AT INITIATION, not completion. The blocking form deadlocked any
6794            // caller whose own request lane rides the module being restarted: the
6795            // caller's in-flight request keeps the drain from quiescing, the drain
6796            // keeps the restart from completing, and the completion keeps the reply
6797            // from releasing the caller — so the drain always timed out and cut the
6798            // initiator with a GOODBYE, even on a healthy module. Replying once the
6799            // restart is validated lets a self-lane caller settle, which is exactly
6800            // what makes the drain succeed. Completion is observable via
6801            // supervisor.list / module status; a post-ack failure lands the module
6802            // in a visible terminal state below rather than in a reply nobody can
6803            // receive.
6804            let validation = match lock_snapshot(snapshot) {
6805                Ok(state) if !state.enabled => Err(SuperviseError::Disabled {
6806                    module_id: spec.module_id.clone(),
6807                }),
6808                Ok(_) => Ok(()),
6809                Err(err) => Err(err),
6810            };
6811            let initiated = validation.is_ok();
6812            let _ = reply.send(validation);
6813            // A restart asks for a fresh process. Commands run one at a time,
6814            // so a restart queued behind another restart (two operator calls
6815            // in quick succession) is dequeued the moment the first one has
6816            // spawned its replacement -- before that process has sent HELLO.
6817            // Running it would drain and kill the process the first restart
6818            // just produced, which is the opposite of what both callers asked
6819            // for. If a process spawned after this request was received is
6820            // still supervised, the request is already satisfied. Not when the
6821            // configuration changed since that spawn: then the newer process
6822            // predates the spec this restart may exist to apply.
6823            let satisfied_by_generation = if initiated && child.is_some() {
6824                lock_snapshot(snapshot).ok().and_then(|state| {
6825                    (state.spawn_generation > received_at_generation
6826                        && !state.configuration_updated_since_spawn)
6827                        .then_some(state.spawn_generation)
6828                })
6829            } else {
6830                None
6831            };
6832            let satisfied_by_pending = initiated
6833                && child.is_none()
6834                && lock_snapshot(snapshot).ok().is_some_and(|mut state| {
6835                    let pending = state.respawn_pending && !state.configuration_updated_since_spawn;
6836                    if pending {
6837                        state.coalesced_restart_pending = true;
6838                    }
6839                    pending
6840                });
6841            if satisfied_by_pending {
6842                debug!(module_id = %spec.module_id, "restart coalesced into the pending replacement");
6843            } else if let Some(generation) = satisfied_by_generation {
6844                info!(
6845                    module_id = %spec.module_id,
6846                    received_at_generation,
6847                    "restart already satisfied by generation {generation}; not restarting again"
6848                );
6849            } else if initiated {
6850                // Precedence: this restart's operator override, else the module's
6851                // configured budget (already resolved into the runtime).
6852                let drain_timeout = drain_timeout_ms
6853                    .map(Duration::from_millis)
6854                    .unwrap_or(runtime.drain_timeout);
6855                if let Err(err) = restart_child(
6856                    spec,
6857                    runtime,
6858                    registry,
6859                    process_liveness,
6860                    snapshot,
6861                    child,
6862                    drain_timeout,
6863                )
6864                .await
6865                {
6866                    warn!(
6867                        module_id = %spec.module_id,
6868                        error = %err,
6869                        "operator restart failed after initiation ack; module state carries the outcome"
6870                    );
6871                    let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
6872                        state.state = ModuleState::Failed;
6873                        clear_current_process_facts(state);
6874                    });
6875                } else {
6876                    runtime.start_requester = requester;
6877                    runtime.pending_activity_restart = true;
6878                }
6879            }
6880            true
6881        }
6882        SupervisorCommand::Reload { requester, reply } => {
6883            let result =
6884                reload_child(spec, runtime, registry, process_liveness, snapshot, child).await;
6885            if result.is_ok() {
6886                runtime.start_requester = requester;
6887                runtime.pending_activity_restart = true;
6888            }
6889            if result.is_ok()
6890                && runtime
6891                    .scheduled_respawn
6892                    .lock()
6893                    .unwrap_or_else(|p| p.into_inner())
6894                    .as_ref()
6895                    .is_some_and(|pending| matches!(pending.kind, RespawnKind::Reload))
6896            {
6897                *runtime
6898                    .deferred_reload_reply
6899                    .lock()
6900                    .unwrap_or_else(|p| p.into_inner()) = Some(reply);
6901            } else {
6902                let _ = reply.send(result);
6903            }
6904            true
6905        }
6906        SupervisorCommand::SetEnabled {
6907            enabled,
6908            requester,
6909            reply,
6910        } => {
6911            let previous_requester = runtime.start_requester.clone();
6912            if enabled {
6913                runtime.start_requester = requester.clone();
6914            }
6915            let result = set_child_enabled(
6916                spec,
6917                runtime,
6918                registry,
6919                process_liveness,
6920                snapshot,
6921                child,
6922                enabled,
6923            )
6924            .await;
6925            if matches!(result, Ok(true)) {
6926                runtime.pending_activity_restart = false;
6927                if enabled {
6928                    runtime.start_requester = requester;
6929                } else {
6930                    runtime.start_requester = None;
6931                    runtime.record_activity_transition(
6932                        "subc.module_disabled",
6933                        &spec.module_id,
6934                        requester,
6935                    );
6936                }
6937            } else {
6938                runtime.start_requester = previous_requester;
6939            }
6940            let _ = reply.send(result);
6941            true
6942        }
6943        SupervisorCommand::UpdateConfiguration {
6944            spec: next_spec,
6945            health,
6946            drain_timeout_ms,
6947            reply,
6948        } => {
6949            if let Some(handle) = &runtime.supervisor_handle {
6950                handle.apply_identity_configuration(&next_spec);
6951            }
6952            *spec = next_spec;
6953            let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
6954                state.configuration_updated_since_spawn = true;
6955            });
6956            let health_changed = runtime.health != health;
6957            runtime.health = health;
6958            // Reset the cadence and old endpoint's failure streak on a live
6959            // health-policy change rather than waiting for its old deadline.
6960            if health_changed {
6961                let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
6962                    state.health = ModuleHealthStatus::default();
6963                });
6964            }
6965            runtime.drain_timeout = drain_timeout_ms
6966                .map(Duration::from_millis)
6967                .unwrap_or(runtime.default_drain_timeout);
6968            *runtime
6969                .effective_drain_timeout
6970                .lock()
6971                .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.drain_timeout;
6972            let _ = reply.send(());
6973            true
6974        }
6975        SupervisorCommand::Swap {
6976            requester,
6977            ready_timeout,
6978            reply,
6979        } => {
6980            let mut swap_runtime = runtime.clone();
6981            swap_runtime.start_requester = requester;
6982            let before = lock_snapshot(snapshot)
6983                .ok()
6984                .map(|state| state.spawn_generation);
6985            let end = swap::run_swap(
6986                spec,
6987                &swap_runtime,
6988                registry,
6989                process_liveness,
6990                snapshot,
6991                child,
6992                commands,
6993                ready_timeout.unwrap_or(DEFAULT_SWAP_READY_TIMEOUT),
6994                reply,
6995            )
6996            .await;
6997            if before
6998                != lock_snapshot(snapshot)
6999                    .ok()
7000                    .map(|state| state.spawn_generation)
7001            {
7002                runtime.start_requester = swap_runtime.start_requester;
7003            }
7004            requeued.extend(end.requeue);
7005            true
7006        }
7007    }
7008}
7009
7010async fn restart_child(
7011    spec: &ModuleSpec,
7012    runtime: &SupervisorRuntimeConfig,
7013    registry: &Registry,
7014    process_liveness: &SupervisorProcessLiveness,
7015    snapshot: &SharedSnapshot,
7016    child: &mut Option<SupervisedChild>,
7017    drain_timeout: Duration,
7018) -> Result<(), SuperviseError> {
7019    // Restart cycles a running module; it must not silently start a disabled one.
7020    if !lock_snapshot(snapshot)?.enabled {
7021        return Err(SuperviseError::Disabled {
7022            module_id: spec.module_id.clone(),
7023        });
7024    }
7025    let stop_notice = begin_forwarding_drain_with_timeout(
7026        spec,
7027        runtime,
7028        registry,
7029        snapshot,
7030        None,
7031        RouteCloseReason::Restart,
7032        drain_timeout,
7033    )
7034    .await?;
7035
7036    if child.is_some() {
7037        drain_optional_child(
7038            &spec.module_id,
7039            spec.protocol,
7040            stop_notice,
7041            registry,
7042            runtime.forwarding.as_deref(),
7043            snapshot,
7044            &runtime.terminal_ring,
7045            &runtime.spawn_events,
7046            child,
7047            drain_timeout,
7048            ModuleState::Restarting,
7049            Some(true),
7050        )
7051        .await?;
7052    } else {
7053        update_snapshot(snapshot, Some(&spec.module_id), |state| {
7054            state.enabled = true;
7055            state.state = ModuleState::Restarting;
7056            clear_current_process_facts(state);
7057        })?;
7058        release_dead_registration(
7059            registry,
7060            runtime.forwarding.as_deref(),
7061            snapshot,
7062            &spec.module_id,
7063        )
7064        .await?;
7065    }
7066
7067    reset_restart_count(snapshot, &spec.module_id)?;
7068    process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
7069    schedule_respawn(
7070        runtime,
7071        snapshot,
7072        &spec.module_id,
7073        runtime.restart_policy.backoff,
7074        RespawnKind::Spawn,
7075    )
7076}
7077
7078async fn reload_child(
7079    spec: &ModuleSpec,
7080    runtime: &SupervisorRuntimeConfig,
7081    registry: &Registry,
7082    process_liveness: &SupervisorProcessLiveness,
7083    snapshot: &SharedSnapshot,
7084    child: &mut Option<SupervisedChild>,
7085) -> Result<(), SuperviseError> {
7086    // Reload cycles a running module; it must not silently start a disabled one.
7087    if !lock_snapshot(snapshot)?.enabled {
7088        return Err(SuperviseError::Disabled {
7089            module_id: spec.module_id.clone(),
7090        });
7091    }
7092    let stop_notice = begin_forwarding_drain(
7093        spec,
7094        runtime,
7095        registry,
7096        snapshot,
7097        Some(true),
7098        RouteCloseReason::Reload,
7099    )
7100    .await?;
7101
7102    if child.is_some() {
7103        drain_optional_child(
7104            &spec.module_id,
7105            spec.protocol,
7106            stop_notice,
7107            registry,
7108            runtime.forwarding.as_deref(),
7109            snapshot,
7110            &runtime.terminal_ring,
7111            &runtime.spawn_events,
7112            child,
7113            runtime.drain_timeout,
7114            ModuleState::Restarting,
7115            Some(true),
7116        )
7117        .await?;
7118    } else {
7119        update_snapshot(snapshot, Some(&spec.module_id), |state| {
7120            state.enabled = true;
7121            state.state = ModuleState::Restarting;
7122            clear_current_process_facts(state);
7123        })?;
7124        release_dead_registration(
7125            registry,
7126            runtime.forwarding.as_deref(),
7127            snapshot,
7128            &spec.module_id,
7129        )
7130        .await?;
7131    }
7132
7133    reset_restart_count(snapshot, &spec.module_id)?;
7134    process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
7135    schedule_respawn(
7136        runtime,
7137        snapshot,
7138        &spec.module_id,
7139        runtime.restart_policy.backoff,
7140        RespawnKind::Reload,
7141    )
7142}
7143
7144async fn finish_reload_child(
7145    spec: &ModuleSpec,
7146    runtime: &SupervisorRuntimeConfig,
7147    registry: &Registry,
7148    process_liveness: &SupervisorProcessLiveness,
7149    snapshot: &SharedSnapshot,
7150    child: &mut Option<SupervisedChild>,
7151) -> Result<(), SuperviseError> {
7152    process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
7153    let next_child = match spawn_and_mark_running(spec, runtime, snapshot) {
7154        Ok(next_child) => next_child,
7155        Err(err) => {
7156            return handle_reload_spawn_failure(
7157                spec,
7158                runtime,
7159                process_liveness,
7160                snapshot,
7161                child,
7162                format!("new child failed to spawn: {err}"),
7163            )
7164            .await;
7165        }
7166    };
7167    *child = Some(next_child);
7168
7169    let wait_outcome = {
7170        let active_child = child.as_mut().expect("new reload child was just stored");
7171        wait_for_registration_after_reload(
7172            registry,
7173            &spec.module_id,
7174            snapshot,
7175            active_child,
7176            REGISTRY_RELEASE_TIMEOUT,
7177        )
7178        .await?
7179    };
7180
7181    match wait_outcome {
7182        RegistrationWaitOutcome::Registered => {
7183            debug!(module_id = %spec.module_id, "supervised module reloaded and registered");
7184            Ok(())
7185        }
7186        RegistrationWaitOutcome::Exited(exit_report) => {
7187            if let Some(active_child) = child.as_mut() {
7188                active_child.drain_stderr(&spec.module_id).await;
7189            }
7190            // Keep the reaped child's roster guard until its terminal is written.
7191            // Shutdown waits on that guard, not on the child Option used for respawn.
7192            let mut exited_child = child.take().expect("exited reload child is still stored");
7193            #[cfg(test)]
7194            if let Some(gate) = &runtime.test_reload_exit_record_gate {
7195                gate.reached.notify_one();
7196                gate.resume.notified().await;
7197            }
7198            let result = handle_reload_child_registration_failure(
7199                spec,
7200                runtime,
7201                registry,
7202                process_liveness,
7203                snapshot,
7204                child,
7205                ReloadRegistrationFailure {
7206                    exit_report: registration_failure_exit_report(exit_report),
7207                    reason: exited_child
7208                        .spawn_failure
7209                        .clone()
7210                        .unwrap_or_else(|| "new child exited before registering".to_string()),
7211                },
7212            )
7213            .await;
7214            exited_child.release_roster();
7215            result
7216        }
7217        RegistrationWaitOutcome::TimedOut => {
7218            let mut timed_out_child = child
7219                .take()
7220                .expect("timed-out reload child is still running");
7221            timed_out_child
7222                .start_kill()
7223                .map_err(|source| SuperviseError::Kill {
7224                    module_id: spec.module_id.clone(),
7225                    source,
7226                })?;
7227            let status = timed_out_child
7228                .wait()
7229                .await
7230                .map_err(|source| SuperviseError::Wait {
7231                    module_id: spec.module_id.clone(),
7232                    source,
7233                })?;
7234            timed_out_child.drain_stderr(&spec.module_id).await;
7235            handle_reload_child_registration_failure(
7236                spec,
7237                runtime,
7238                registry,
7239                process_liveness,
7240                snapshot,
7241                child,
7242                ReloadRegistrationFailure {
7243                    exit_report: registration_failure_exit_report(classify_reaped_child_exit(
7244                        snapshot,
7245                        &timed_out_child,
7246                        &status,
7247                    )),
7248                    reason: format!(
7249                        "new child did not register within {:?}",
7250                        REGISTRY_RELEASE_TIMEOUT
7251                    ),
7252                },
7253            )
7254            .await
7255        }
7256    }
7257}
7258
7259async fn set_child_enabled(
7260    spec: &ModuleSpec,
7261    runtime: &SupervisorRuntimeConfig,
7262    registry: &Registry,
7263    process_liveness: &SupervisorProcessLiveness,
7264    snapshot: &SharedSnapshot,
7265    child: &mut Option<SupervisedChild>,
7266    enabled: bool,
7267) -> Result<bool, SuperviseError> {
7268    let (current_enabled, current_state, respawn_pending) = {
7269        let state = lock_snapshot(snapshot)?;
7270        (state.enabled, state.state, state.respawn_pending)
7271    };
7272    // `start` (enable on an already-enabled module) heals TERMINAL states instead
7273    // of no-op'ing: a module whose restart budget exhausted (Failed) or that exited
7274    // clean (Stopped) has no live process and no other in-band recovery — the
7275    // operator's start is the explicit recovery act and resets the budget. Without
7276    // this arm the only revival was subc-probe --supervisor-restart in a terminal,
7277    // which the 2026-07-14 aft outage proved is a trap when the failed module is
7278    // the one providing every agent's shell.
7279    let revive_terminal = enabled
7280        && current_enabled
7281        && child.is_none()
7282        && (matches!(current_state, ModuleState::Failed | ModuleState::Stopped)
7283            || (current_state == ModuleState::Restarting && !respawn_pending));
7284    if current_enabled == enabled && !revive_terminal {
7285        return Ok(false);
7286    }
7287
7288    if enabled {
7289        update_snapshot(snapshot, Some(&spec.module_id), |state| {
7290            state.enabled = true;
7291            state.state = ModuleState::Starting;
7292            clear_current_process_facts(state);
7293        })?;
7294        #[cfg(test)]
7295        if runtime.test_seed_stale_facts_before_enable_spawn {
7296            update_snapshot(snapshot, Some(&spec.module_id), |state| {
7297                state.process_alive = true;
7298                state.pid = Some(41);
7299                state.spawned_at_ms = Some(42);
7300                state.spawned_from = Some(PathBuf::from("/spawned/module"));
7301                state.spawned_file_identity = Some(SpawnedFileIdentity {
7302                    device: 43,
7303                    inode: 44,
7304                    #[cfg(windows)]
7305                    windows_image: None,
7306                });
7307            })?;
7308        }
7309        release_dead_registration(
7310            registry,
7311            runtime.forwarding.as_deref(),
7312            snapshot,
7313            &spec.module_id,
7314        )
7315        .await?;
7316        reset_restart_count(snapshot, &spec.module_id)?;
7317        process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
7318        let next_child = match spawn_and_mark_running(spec, runtime, snapshot) {
7319            Ok(next_child) => next_child,
7320            Err(err) => {
7321                if let Err(state_err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
7322                    state.state = ModuleState::Failed;
7323                    clear_current_process_facts(state);
7324                }) {
7325                    error!(module_id = %spec.module_id, error = %state_err, "failed to record enable spawn failure");
7326                }
7327                process_liveness.untrack_if_current(&spec.module_id, snapshot);
7328                return Err(err);
7329            }
7330        };
7331        *child = Some(next_child);
7332        debug!(module_id = %spec.module_id, "supervised module enabled");
7333        Ok(true)
7334    } else {
7335        let stop_notice = begin_forwarding_drain_if_configured(
7336            spec,
7337            runtime,
7338            registry,
7339            snapshot,
7340            Some(false),
7341            RouteCloseReason::Disable,
7342        )
7343        .await?;
7344        drain_optional_child(
7345            &spec.module_id,
7346            spec.protocol,
7347            stop_notice,
7348            registry,
7349            runtime.forwarding.as_deref(),
7350            snapshot,
7351            &runtime.terminal_ring,
7352            &runtime.spawn_events,
7353            child,
7354            runtime.drain_timeout,
7355            ModuleState::Disabled,
7356            Some(false),
7357        )
7358        .await?;
7359        debug!(module_id = %spec.module_id, "supervised module disabled");
7360        Ok(true)
7361    }
7362}
7363
7364#[allow(clippy::too_many_arguments)]
7365async fn on_child_exit(
7366    spec: &ModuleSpec,
7367    policy: RestartPolicy,
7368    registry: &Registry,
7369    snapshot: &SharedSnapshot,
7370    terminal_ring: &Arc<Mutex<TerminalRing>>,
7371    spawn_events: &SpawnEventFeed,
7372    roster: &ChildRoster,
7373    exit_report: ExitReport,
7374) -> NextAction {
7375    // Once the daemon has begun shutting down, no exit is a crash to recover
7376    // from: the module is exiting because the daemon is going away (EOF on its
7377    // connection, or a service manager signalling the whole cgroup). Record it
7378    // as such and never schedule a respawn, which would only start a process
7379    // for the shutdown to end again.
7380    if roster.is_closed() {
7381        return on_child_exit_during_daemon_shutdown(
7382            spec,
7383            registry,
7384            snapshot,
7385            terminal_ring,
7386            spawn_events,
7387            exit_report,
7388        )
7389        .await;
7390    }
7391    // Every stop the supervisor itself asks for (operator stop, disable,
7392    // restart, reload, swap, a health restart, a drain that runs out of budget)
7393    // takes the child out of the supervise loop and reaps it in
7394    // `drain_child_to_state`, and daemon shutdown is handled above. So an exit
7395    // that reaches this point was not requested by the daemon.
7396    //
7397    // For a subc-wire module a clean exit is still a stop: those modules are
7398    // written to re-raise SIGTERM, so a stray outside signal already reads as a
7399    // crash, and exiting 0 is a deliberate choice the module made. A
7400    // `protocol: "none"` module is a stock program we cannot change, and many
7401    // of them (nats-server among them) exit 0 on SIGTERM. Treating that as a
7402    // stop would leave the module down for good after any stray signal, so it
7403    // goes through the crash path instead: it spends restart budget, respawns
7404    // with the crash backoff, and ends `failed` when the budget runs out.
7405    let unrequested_clean_exit_of_protocol_none = exit_report.kind == ExitKind::Clean
7406        && running_protocol(spec, snapshot) == ModuleProtocol::None;
7407    match exit_report.kind {
7408        ExitKind::Clean if !unrequested_clean_exit_of_protocol_none => {
7409            info!(
7410                module_id = %spec.module_id,
7411                exit_code = ?exit_report.code,
7412                exit_signal = ?exit_report.signal,
7413                "supervised module exited cleanly"
7414            );
7415            if let Err(err) = publish_after_terminal_record(
7416                &spec.module_id,
7417                || {
7418                    record_terminal(
7419                        &spec.module_id,
7420                        terminal_ring,
7421                        spawn_events,
7422                        &exit_report,
7423                        TerminalDisposition::Stopped,
7424                    )
7425                },
7426                || {
7427                    update_snapshot(snapshot, Some(&spec.module_id), |state| {
7428                        state.state = ModuleState::Stopped;
7429                        clear_current_process_facts(state);
7430                        state.last_exit = Some(exit_report.clone());
7431                    })
7432                },
7433            ) {
7434                error!(module_id = %spec.module_id, error = %err, "failed to publish clean module exit");
7435            }
7436            let registration_released = match wait_for_registration_release(
7437                registry,
7438                &spec.module_id,
7439                REGISTRY_RELEASE_TIMEOUT,
7440            )
7441            .await
7442            {
7443                Ok(()) => true,
7444                Err(err) => {
7445                    warn!(module_id = %spec.module_id, error = %err, "registration still active after clean exit");
7446                    false
7447                }
7448            };
7449            NextAction::Stop {
7450                registration_released,
7451            }
7452        }
7453        ExitKind::Clean | ExitKind::Crash => {
7454            if unrequested_clean_exit_of_protocol_none {
7455                warn!(
7456                    module_id = %spec.module_id,
7457                    exit_code = ?exit_report.code,
7458                    exit_signal = ?exit_report.signal,
7459                    "protocol-none module exited cleanly without a stop request; handling it as a crash"
7460                );
7461            } else {
7462                warn!(
7463                    module_id = %spec.module_id,
7464                    exit_code = ?exit_report.code,
7465                    exit_signal = ?exit_report.signal,
7466                    "supervised module exited abnormally (crash)"
7467                );
7468            }
7469            let mut restart_schedule = None;
7470            let mut disposition = TerminalDisposition::Disabled;
7471            let mut final_state = ModuleState::Disabled;
7472            // Set only when the budget is what stopped the module, so the
7473            // terminal record says which limit was hit rather than leaving
7474            // `failed` to be read as "crashed once, badly".
7475            let mut disposition_detail = lock_snapshot(snapshot)
7476                .ok()
7477                .and_then(|mut state| state.spawn_failure.take());
7478            let now = Instant::now();
7479            if let Err(err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
7480                if state.enabled {
7481                    if let Some(schedule) = state.next_crash_restart(&policy, now) {
7482                        // Reserving the last slot exhausts the budget for the
7483                        // replacement, not for this exit. Publish its scheduled
7484                        // recovery atomically with the reservation so connection
7485                        // cleanup sees it while terminal recording still holds
7486                        // the visible state at Running.
7487                        state.respawn_pending = true;
7488                        final_state = ModuleState::Restarting;
7489                        restart_schedule = Some(schedule);
7490                        disposition = TerminalDisposition::Restarting;
7491                    } else {
7492                        final_state = ModuleState::Failed;
7493                        disposition = TerminalDisposition::Failed;
7494                        let budget = policy.budget_exhausted_detail();
7495                        disposition_detail =
7496                            Some(disposition_detail.take().map_or_else(
7497                                || budget.clone(),
7498                                |cause| format!("{cause}; {budget}"),
7499                            ));
7500                    }
7501                } else {
7502                    disposition = TerminalDisposition::Disabled;
7503                }
7504            }) {
7505                error!(module_id = %spec.module_id, error = %err, "failed to record crashed module exit");
7506                return NextAction::Stop {
7507                    registration_released: false,
7508                };
7509            }
7510            if disposition == TerminalDisposition::Failed {
7511                // The window is in the message, not only in the fields: this line
7512                // is read in a scrollback where a bare `max_restarts=3` reads as a
7513                // lifetime cap and sends the operator looking for three crashes
7514                // that never happened together.
7515                error!(
7516                    module_id = %spec.module_id,
7517                    max_restarts = policy.max_restarts,
7518                    window_secs = policy.window.as_secs(),
7519                    "module stopped: {}",
7520                    policy.budget_exhausted_detail()
7521                );
7522            }
7523            if let Err(err) = publish_after_terminal_record(
7524                &spec.module_id,
7525                || {
7526                    record_terminal_with_detail(
7527                        &spec.module_id,
7528                        terminal_ring,
7529                        spawn_events,
7530                        &exit_report,
7531                        disposition,
7532                        disposition_detail,
7533                    )
7534                },
7535                || {
7536                    update_snapshot(snapshot, Some(&spec.module_id), |state| {
7537                        state.state = final_state;
7538                        clear_current_process_facts(state);
7539                        state.last_exit = Some(exit_report.clone());
7540                    })
7541                },
7542            ) {
7543                error!(module_id = %spec.module_id, error = %err, "failed to publish crashed module exit");
7544            }
7545
7546            if let Some(schedule) = restart_schedule {
7547                NextAction::Restart {
7548                    schedule: Some(schedule),
7549                }
7550            } else {
7551                let registration_released = match wait_for_registration_release(
7552                    registry,
7553                    &spec.module_id,
7554                    REGISTRY_RELEASE_TIMEOUT,
7555                )
7556                .await
7557                {
7558                    Ok(()) => true,
7559                    Err(err) => {
7560                        warn!(module_id = %spec.module_id, error = %err, "registration still active after failed module");
7561                        false
7562                    }
7563                };
7564                NextAction::Stop {
7565                    registration_released,
7566                }
7567            }
7568        }
7569        ExitKind::DeliberateSeverance => {
7570            warn!(
7571                module_id = %spec.module_id,
7572                exit_code = ?exit_report.code,
7573                exit_signal = ?exit_report.signal,
7574                "supervised module exited after deliberate connection severance"
7575            );
7576            let should_restart = match lock_snapshot(snapshot) {
7577                Ok(state) => state.enabled,
7578                Err(err) => {
7579                    error!(module_id = %spec.module_id, error = %err, "failed to read deliberately severed module state");
7580                    return NextAction::Stop {
7581                        registration_released: false,
7582                    };
7583                }
7584            };
7585            let final_state = if should_restart {
7586                ModuleState::Restarting
7587            } else {
7588                ModuleState::Disabled
7589            };
7590            if let Err(err) = publish_after_terminal_record(
7591                &spec.module_id,
7592                || {
7593                    record_terminal(
7594                        &spec.module_id,
7595                        terminal_ring,
7596                        spawn_events,
7597                        &exit_report,
7598                        terminal_disposition(final_state),
7599                    )
7600                },
7601                || {
7602                    update_snapshot(snapshot, Some(&spec.module_id), |state| {
7603                        state.state = final_state;
7604                        clear_current_process_facts(state);
7605                        state.last_exit = Some(exit_report.clone());
7606                        state.lifetime_restarts += 1;
7607                    })
7608                },
7609            ) {
7610                error!(module_id = %spec.module_id, error = %err, "failed to publish deliberately severed module exit");
7611                return NextAction::Stop {
7612                    registration_released: false,
7613                };
7614            }
7615
7616            if should_restart {
7617                NextAction::Restart { schedule: None }
7618            } else {
7619                let registration_released = match wait_for_registration_release(
7620                    registry,
7621                    &spec.module_id,
7622                    REGISTRY_RELEASE_TIMEOUT,
7623                )
7624                .await
7625                {
7626                    Ok(()) => true,
7627                    Err(err) => {
7628                        warn!(module_id = %spec.module_id, error = %err, "registration still active after deliberately severed module exit");
7629                        false
7630                    }
7631                };
7632                NextAction::Stop {
7633                    registration_released,
7634                }
7635            }
7636        }
7637    }
7638}
7639
7640async fn on_child_exit_during_daemon_shutdown(
7641    spec: &ModuleSpec,
7642    registry: &Registry,
7643    snapshot: &SharedSnapshot,
7644    terminal_ring: &Arc<Mutex<TerminalRing>>,
7645    spawn_events: &SpawnEventFeed,
7646    exit_report: ExitReport,
7647) -> NextAction {
7648    info!(
7649        module_id = %spec.module_id,
7650        exit_code = ?exit_report.code,
7651        exit_signal = ?exit_report.signal,
7652        exit_kind = ?exit_report.kind,
7653        "supervised module exited during daemon shutdown; not restarting it"
7654    );
7655    if let Err(err) = publish_after_terminal_record(
7656        &spec.module_id,
7657        || {
7658            record_terminal(
7659                &spec.module_id,
7660                terminal_ring,
7661                spawn_events,
7662                &exit_report,
7663                TerminalDisposition::DaemonShutdown,
7664            )
7665        },
7666        || {
7667            update_snapshot(snapshot, Some(&spec.module_id), |state| {
7668                state.state = ModuleState::Stopped;
7669                clear_current_process_facts(state);
7670                state.last_exit = Some(exit_report.clone());
7671            })
7672        },
7673    ) {
7674        error!(module_id = %spec.module_id, error = %err, "failed to publish module exit during daemon shutdown");
7675    }
7676    let registration_released =
7677        wait_for_registration_release(registry, &spec.module_id, REGISTRY_RELEASE_TIMEOUT)
7678            .await
7679            .is_ok();
7680    NextAction::Stop {
7681        registration_released,
7682    }
7683}
7684
7685// Keep the status lock out of terminal I/O. Readers may observe the previous
7686// state while recording is in progress, but never a completed exit without its
7687// history. Journal errors are logged by the journal and retained in the ring;
7688// a recording panic still publishes the final state before resuming the unwind.
7689fn publish_after_terminal_record<T>(
7690    module_id: &str,
7691    record: impl FnOnce(),
7692    publish: impl FnOnce() -> T,
7693) -> T {
7694    let recorded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(record));
7695    let published = publish();
7696    if let Err(panic) = recorded {
7697        error!(
7698            module_id,
7699            "terminal recording panicked; published final state before resuming unwind"
7700        );
7701        std::panic::resume_unwind(panic);
7702    }
7703    published
7704}
7705
7706fn fail_after_wait_error(
7707    module_id: &str,
7708    snapshot: &SharedSnapshot,
7709    terminal_ring: &Arc<Mutex<TerminalRing>>,
7710    spawn_events: &SpawnEventFeed,
7711    detail: String,
7712) {
7713    let report = wait_error_exit_report();
7714    publish_after_terminal_record(
7715        module_id,
7716        || {
7717            record_terminal_with_detail(
7718                module_id,
7719                terminal_ring,
7720                spawn_events,
7721                &report,
7722                TerminalDisposition::Failed,
7723                Some(detail),
7724            )
7725        },
7726        || fail_snapshot(snapshot, Some(module_id), Some(report.clone())),
7727    );
7728}
7729
7730#[cfg(test)]
7731fn record_wait_error_terminal(
7732    module_id: &str,
7733    terminal_ring: &Arc<Mutex<TerminalRing>>,
7734    spawn_events: &SpawnEventFeed,
7735) {
7736    record_terminal(
7737        module_id,
7738        terminal_ring,
7739        spawn_events,
7740        &wait_error_exit_report(),
7741        TerminalDisposition::Failed,
7742    );
7743}
7744
7745fn record_terminal(
7746    module_id: &str,
7747    terminal_ring: &Arc<Mutex<TerminalRing>>,
7748    spawn_events: &SpawnEventFeed,
7749    exit_report: &ExitReport,
7750    disposition: TerminalDisposition,
7751) {
7752    record_terminal_with_detail(
7753        module_id,
7754        terminal_ring,
7755        spawn_events,
7756        exit_report,
7757        disposition,
7758        None,
7759    );
7760}
7761
7762/// The ring lock is held only to capture the read (see
7763/// `TerminalJournal::capture_read`), so this module's exits keep recording
7764/// while the journal files are read. Blocking: it reads files.
7765fn durable_terminal_history_of(
7766    terminal_ring: &Mutex<TerminalRing>,
7767    module_id: &str,
7768) -> subc_control::TerminalHistory {
7769    let read = terminal_ring
7770        .lock()
7771        .unwrap_or_else(|p| p.into_inner())
7772        .capture_durable_history();
7773    read.read(module_id)
7774}
7775
7776fn record_terminal_with_detail(
7777    module_id: &str,
7778    terminal_ring: &Arc<Mutex<TerminalRing>>,
7779    spawn_events: &SpawnEventFeed,
7780    exit_report: &ExitReport,
7781    disposition: TerminalDisposition,
7782    disposition_detail: Option<String>,
7783) {
7784    #[cfg(test)]
7785    terminal_history_tests::before_terminal_record(spawn_events);
7786    spawn_events.emit_exited(module_id, exit_report.code, exit_report.signal);
7787    let record = TerminalRecord {
7788        exit_code: exit_report.code,
7789        exit_signal: exit_report.signal,
7790        at_ms: exit_report.at_ms,
7791        disposition,
7792        exit_kind: exit_report.kind.into(),
7793        disposition_detail,
7794    };
7795    terminal_ring
7796        .lock()
7797        .unwrap_or_else(|poisoned| poisoned.into_inner())
7798        .record_exit(module_id, record);
7799}
7800
7801fn untrack_if_registration_released(
7802    process_liveness: &SupervisorProcessLiveness,
7803    registry: &Registry,
7804    module_id: &str,
7805    snapshot: &SharedSnapshot,
7806) {
7807    match registry.get_module(module_id) {
7808        Ok(None) => process_liveness.untrack_if_current(module_id, snapshot),
7809        Ok(Some(_)) => {}
7810        Err(err) => {
7811            warn!(module_id, error = %err, "could not determine whether supervisor liveness can be untracked");
7812        }
7813    }
7814}
7815
7816/// The child's environment plan: inherit the parent's, drop ambient `CK_LOG`,
7817/// then apply the module's configured entries minus daemon-private capture keys.
7818///
7819/// Separated from `spawn_child` only so it can be asserted without spawning a
7820/// process — a duplicate of this logic in a test would pass while the real one
7821/// drifted, which is the defect class this function exists to avoid.
7822/// The subc-wire half of a spawn: `--subc <connection file>` and the launch
7823/// nonce. A `protocol: "none"` module gets neither, because it cannot use
7824/// either and the argument would stop a stock binary from starting at all.
7825/// `SUBC_MODULE_ID` is set on every path since an unread variable is inert.
7826///
7827/// The plain-spawn form, kept for the tests that assert its plan; spawns go
7828/// through [`apply_wire_spawn_args_for_role`].
7829#[cfg(test)]
7830fn apply_wire_spawn_args(
7831    command: &mut Command,
7832    spec: &ModuleSpec,
7833    connection_file_path: Option<&std::path::Path>,
7834    handle: Option<&SupervisorHandle>,
7835) -> Result<Option<NonceHandoff>, SuperviseError> {
7836    apply_wire_spawn_args_for_role(
7837        command,
7838        spec,
7839        connection_file_path,
7840        handle,
7841        SpawnRole::Plain,
7842    )
7843}
7844
7845/// The read end of a spawn's launch-nonce pipe, prepared by
7846/// [`apply_wire_spawn_args_for_role`] and installed as the child's descriptor 3
7847/// by the last pre-exec step, just before `spawn()`. Windows prepares a named
7848/// server before spawn and authenticates its client after the child exists.
7849#[cfg(unix)]
7850type NonceHandoff = subc_os::LaunchNonceHandoff;
7851#[cfg(windows)]
7852type NonceHandoff = subc_os::LaunchNoncePipeHandoff;
7853#[cfg(not(any(unix, windows)))]
7854type NonceHandoff = std::convert::Infallible;
7855
7856/// Create the launch nonce that a new module process must present when it
7857/// registers or opens a consumer connection, including a replacement process
7858/// started alongside a still-serving module.
7859///
7860/// A plain spawn replaces the module's recorded nonce. A swap candidate records
7861/// a separate candidate token so the still-serving incumbent and its consumers
7862/// keep their nonce. Both records are installed before the process exists, so
7863/// the child's initial HELLO registration cannot arrive ahead of its nonce.
7864///
7865/// On Unix the nonce is delivered only through a pipe. It is written into
7866/// a pipe whose read end the child gets as descriptor 3, named by
7867/// `SUBC_LAUNCH_NONCE_FD=3:<pipe inode>`: unlike the environment, another
7868/// process of the same user cannot read it with `ps eww`. That handoff is
7869/// returned rather than installed here, because installing it replaces
7870/// whatever the child has at descriptor 3 and so must be the last pre-exec
7871/// step. Later in `spawn_child_in_slot`, `apply_cgroup_placement` first registers
7872/// the Linux callback that writes the process into its cgroup. Only after that
7873/// does `handoff.install_last` register the descriptor-3 replacement, so it
7874/// cannot close a descriptor the cgroup callback still needs.
7875/// Windows keeps SUBC_LAUNCH_NONCE as well until every module reads the named pipe.
7876fn apply_wire_spawn_args_for_role(
7877    command: &mut Command,
7878    spec: &ModuleSpec,
7879    connection_file_path: Option<&std::path::Path>,
7880    handle: Option<&SupervisorHandle>,
7881    role: SpawnRole,
7882) -> Result<Option<NonceHandoff>, SuperviseError> {
7883    command.env(SUBC_MODULE_ID_ENV, &spec.module_id);
7884    // SUBC_LAUNCH_NONCE_FD is removed for every child, `protocol: "none"`
7885    // included: a daemon started from a module's process tree inherits it,
7886    // and passing it on would point the child at a descriptor it does not
7887    // have.
7888    command.env_remove(subc_os::LAUNCH_NONCE_FD_ENV);
7889    #[cfg(windows)]
7890    {
7891        command.env_remove(subc_os::LAUNCH_NONCE_PIPE_ENV);
7892        command.env_remove(subc_os::LAUNCH_NONCE_PIPE_FALLBACK_ENV);
7893    }
7894    // Remove inherited or configured copies too: withholding must mean absent.
7895    command.env_remove(SUBC_LAUNCH_NONCE_ENV);
7896    if spec.protocol == ModuleProtocol::None {
7897        return Ok(None);
7898    }
7899    if let Some(connection_file_path) = connection_file_path {
7900        command.arg(SUBC_ARG).arg(connection_file_path);
7901    }
7902
7903    // Every subc-wire spawn receives a fresh one-time launch nonce for consumer
7904    // route.open attestation. Reserved modules additionally use the same nonce
7905    // for HELLO id-squatting protection. A respawn rotates both records.
7906    let nonce = generate_launch_nonce()?;
7907    if let Some(handle) = handle {
7908        match role {
7909            SpawnRole::Plain => {
7910                handle.set_spawn_nonce(&spec.module_id, nonce.clone());
7911                if spec.reserved {
7912                    handle.set_reserved_nonce(&spec.module_id, nonce.clone());
7913                }
7914            }
7915            SpawnRole::SwapCandidate => handle.open_swap(&spec.module_id, nonce.clone()),
7916        }
7917    }
7918    #[cfg(unix)]
7919    let handoff = {
7920        let handoff =
7921            subc_os::LaunchNonceHandoff::new(&nonce).map_err(|source| SuperviseError::Spawn {
7922                program: spec.program.clone(),
7923                source,
7924                cgroup_path: None,
7925            })?;
7926        command.env(subc_os::LAUNCH_NONCE_FD_ENV, handoff.fd_env_value());
7927        Some(handoff)
7928    };
7929    #[cfg(windows)]
7930    let handoff = prepare_windows_nonce_pipe(command, spec, &nonce);
7931    #[cfg(not(any(unix, windows)))]
7932    let handoff = None;
7933    // Older modules still read SUBC_LAUNCH_NONCE to register. Keep that copy
7934    // until the daemon's per-spawn delivery records show that every running
7935    // Windows module has read its secret from the named pipe.
7936    #[cfg(not(unix))]
7937    command.env(SUBC_LAUNCH_NONCE_ENV, nonce);
7938    Ok(handoff)
7939}
7940
7941fn apply_child_env(command: &mut Command, spec: &ModuleSpec) {
7942    command.env_remove(CK_LOG_ENV);
7943    // The spawn role is the supervisor's to set, and only on a swap candidate
7944    // (see `apply_spawn_role`). Removing it here, rather than just not setting
7945    // it, is what makes it absent on a plain spawn: the daemon's own
7946    // environment could carry it, and so could a spec built outside daemon
7947    // config (config refuses it as an `env` key). A module reading it on a
7948    // plain restart would pick the long swap budget and leave callers waiting.
7949    command.env_remove(SUBC_SPAWN_ROLE_ENV);
7950    for (key, value) in &spec.env {
7951        // cortexkit-log currently exposes retention only as a Rust struct, not
7952        // environment names. These values are daemon-private sink metadata and
7953        // must never become a public child-process contract by being inherited.
7954        if matches!(
7955            key.as_str(),
7956            CAPTURE_MAX_FILE_MB_ENV | CAPTURE_KEEP_ENV | CAPTURE_MAX_AGE_DAYS_ENV
7957        ) || key == SUBC_SPAWN_ROLE_ENV
7958        {
7959            continue;
7960        }
7961        command.env(key, value);
7962    }
7963}
7964
7965#[cfg(windows)]
7966fn prepare_windows_nonce_pipe(
7967    command: &mut Command,
7968    spec: &ModuleSpec,
7969    nonce: &str,
7970) -> Option<NonceHandoff> {
7971    match subc_os::LaunchNoncePipeHandoff::new(nonce) {
7972        Ok(handoff) => {
7973            command.env(subc_os::LAUNCH_NONCE_PIPE_ENV, handoff.name());
7974            // Let a failed pipe read use SUBC_LAUNCH_NONCE so the process can
7975            // still register until every module reads the pipe. A reader without
7976            // this variable set to env returns the pipe error instead of using
7977            // an inherited or configured environment copy.
7978            command.env(subc_os::LAUNCH_NONCE_PIPE_FALLBACK_ENV, "env");
7979            Some(handoff)
7980        }
7981        Err(error) => {
7982            warn!(module_id = %spec.module_id, %error, "could not prepare launch nonce pipe; retaining the environment handoff");
7983            None
7984        }
7985    }
7986}
7987
7988#[cfg(windows)]
7989fn start_windows_nonce_pipe(
7990    handoff: Option<NonceHandoff>,
7991    pid: u32,
7992    spec: &ModuleSpec,
7993) -> Option<subc_os::LaunchNoncePipeDelivery> {
7994    // Close an unread pipe after REGISTRY_RELEASE_TIMEOUT (one second), the
7995    // same maximum wait used for registration during a reload. Older modules
7996    // may register using SUBC_LAUNCH_NONCE without ever connecting to the pipe.
7997    handoff.and_then(|handoff| match handoff.serve(pid, REGISTRY_RELEASE_TIMEOUT) {
7998        Ok(delivery) => Some(delivery),
7999        Err(error) => {
8000            warn!(module_id = %spec.module_id, %error, "could not serve launch nonce pipe; retaining the environment handoff");
8001            None
8002        }
8003    })
8004}
8005
8006fn observed_launch_nonce_source(snapshot: &SupervisorSnapshot) -> Option<String> {
8007    #[cfg(windows)]
8008    if snapshot.process_alive && snapshot.spawned_protocol == Some(ModuleProtocol::Subc) {
8009        return Some(
8010            if snapshot
8011                .pipe_consumed
8012                .as_ref()
8013                .is_some_and(|consumed| consumed.get().is_some())
8014            {
8015                "pipe"
8016            } else {
8017                "env"
8018            }
8019            .to_owned(),
8020        );
8021    }
8022    let _ = snapshot;
8023    None
8024}
8025
8026/// Which slot a spawn fills: the module's ordinary one, or the candidate slot
8027/// of a blue/green swap.
8028#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8029enum SpawnRole {
8030    Plain,
8031    SwapCandidate,
8032}
8033
8034/// Set the spawn role for a swap candidate. A plain spawn gets nothing here;
8035/// `apply_child_env` has already removed the variable for every spawn.
8036fn apply_spawn_role(command: &mut Command, role: SpawnRole) {
8037    if role == SpawnRole::SwapCandidate {
8038        command.env(SUBC_SPAWN_ROLE_ENV, SPAWN_ROLE_SWAP_CANDIDATE);
8039    }
8040}
8041
8042#[cfg(target_os = "linux")]
8043fn previous_instance_error(spec: &ModuleSpec, pid: u32) -> SuperviseError {
8044    SuperviseError::Spawn {
8045        program: spec.program.clone(),
8046        source: io::Error::new(io::ErrorKind::WouldBlock, format!(
8047            "waiting: previous instance (pid {pid}) is still running but hidden; it cannot be identified"
8048        )),
8049        cgroup_path: None,
8050    }
8051}
8052
8053fn spawn_child(
8054    spec: &ModuleSpec,
8055    connection_file_path: Option<&std::path::Path>,
8056    handle: Option<&SupervisorHandle>,
8057    ring: &Arc<Mutex<StderrRing>>,
8058    capture_logs_dir: Option<&std::path::Path>,
8059    roster: &ChildRoster,
8060    #[cfg(target_os = "linux")] cgroup_placement: Option<&subc_cgroup::Placement>,
8061) -> Result<SupervisedChild, SuperviseError> {
8062    spawn_child_in_slot(
8063        spec,
8064        connection_file_path,
8065        handle,
8066        ring,
8067        capture_logs_dir,
8068        roster,
8069        #[cfg(target_os = "linux")]
8070        cgroup_placement,
8071        SpawnRole::Plain,
8072        false,
8073    )
8074}
8075
8076/// Spawn one process of `spec` into a slot.
8077///
8078/// `alternate_slot` picks the process's cgroup label on Linux (see `swap::cgroup_name`).
8079/// A swap candidate needs a different cgroup from the process it is replacing,
8080/// which is still alive: in the same cgroup the two would be one kill domain,
8081/// and killing a failed candidate could take the incumbent with it.
8082///
8083/// The stderr capture file is `<module_id>.stderr.log` for every process of
8084/// the module, whichever slot it is in, because that is the one file
8085/// `ck module logs` reads. During a swap's overlap both processes append to it;
8086/// the daemon writes whole lines, so the two interleave by line, which is also
8087/// the merged view an operator wants while a swap runs.
8088#[allow(clippy::too_many_arguments)]
8089fn spawn_child_in_slot(
8090    spec: &ModuleSpec,
8091    connection_file_path: Option<&std::path::Path>,
8092    handle: Option<&SupervisorHandle>,
8093    ring: &Arc<Mutex<StderrRing>>,
8094    capture_logs_dir: Option<&std::path::Path>,
8095    roster: &ChildRoster,
8096    #[cfg(target_os = "linux")] cgroup_placement: Option<&subc_cgroup::Placement>,
8097    role: SpawnRole,
8098    alternate_slot: bool,
8099) -> Result<SupervisedChild, SuperviseError> {
8100    #[cfg(target_os = "linux")]
8101    if let Some(pid) = roster.previous_instance_pid(&spec.module_id) {
8102        return Err(previous_instance_error(spec, pid));
8103    }
8104    if roster.is_closed() {
8105        return Err(SuperviseError::Spawn {
8106            program: spec.program.clone(),
8107            source: io::Error::other("the daemon is shutting down; not starting a new process"),
8108            cgroup_path: None,
8109        });
8110    }
8111    #[cfg(target_os = "linux")]
8112    let cgroup_name = {
8113        // Slot names alone are not kill domains: a retired incumbent may still
8114        // be draining when a later enable/restart spawns into the same slot.
8115        // Decimal entropy keeps the suffix unambiguous; Placement performs
8116        // the module-id escaping and constructs the filesystem path.
8117        if cgroup_placement.is_none() {
8118            swap::cgroup_name(&spec.module_id, alternate_slot)
8119        } else {
8120            let nonce = generate_launch_nonce()?;
8121            let suffix = u128::from_str_radix(&nonce[..32], 16).expect("hex launch nonce");
8122            // Leave room for byte escaping and the suffix under NAME_MAX. The
8123            // label is only for humans; the nonce identifies the kill domain.
8124            let mut end = spec.module_id.len().min(64);
8125            while !spec.module_id.is_char_boundary(end) {
8126                end -= 1;
8127            }
8128            format!(
8129                "{}_{suffix}",
8130                swap::cgroup_name(&spec.module_id[..end], alternate_slot)
8131            )
8132        }
8133    };
8134    #[cfg(not(target_os = "linux"))]
8135    let _ = alternate_slot;
8136    #[cfg(target_os = "macos")]
8137    let (mut command, privacy_exec, exec_ack) = privacy_command(spec, roster)?;
8138    #[cfg(windows)]
8139    let image_capture = windows_spawn_capture(spec).map_err(|source| SuperviseError::Spawn {
8140        program: spec.program.clone(),
8141        source,
8142        cgroup_path: None,
8143    })?;
8144    #[cfg(windows)]
8145    let mut command = Command::new(image_capture.path());
8146    #[cfg(not(any(target_os = "macos", windows)))]
8147    let mut command = Command::new(&spec.program);
8148    command.args(&spec.args);
8149    // AMBIENT `CK_LOG` MUST NOT LEAK INTO AN OTHERWISE UNCONFIGURED MODULE — but
8150    // that is the whole of the intent, so remove that one key rather than the
8151    // environment.
8152    //
8153    // This was `env_clear()` from 0.17.41 until 0.18.3, which achieved the goal
8154    // and took the POSIX environment with it. Modules spawned that way had no
8155    // HOME, XDG_RUNTIME_DIR, TMPDIR or USER, and the consequences ran past
8156    // logging:
8157    //
8158    //   * `connection_file::discover` reads XDG_RUNTIME_DIR and HOME, so with
8159    //     both unset it fell back to the temp dir alone and `ck` could not find
8160    //     a daemon running on the same machine from inside any module's process
8161    //     tree — reporting a path the file has never lived at, which reads as
8162    //     "the daemon did not write its file".
8163    //   * `default_data_home()` with HOME and XDG_DATA_HOME both unset returns
8164    //     the RELATIVE `.local/share`, so a module deriving its own store path
8165    //     resolved it against its own CWD. That is the store-fragmentation
8166    //     defect the daemon already refuses in config (`parse_doc` rejects a
8167    //     relative `storage.data_home`) arriving by derivation instead.
8168    //   * anything a module spawns inherited it: git without ~/.gitconfig,
8169    //     cargo without CARGO_HOME, ssh, python user dirs — all degrading
8170    //     quietly rather than erroring.
8171    //
8172    // Reported by iceteaSA as #104 after deploying 0.18.2, where `ck daemon`
8173    // offered one candidate under /tmp while the file sat in /run/user/1000.
8174    //
8175    // A configured module is unaffected either way: `module_spec()` puts the
8176    // resolved CK_LOG into `spec.env`, which is applied below and therefore
8177    // wins over anything ambient.
8178    apply_child_env(&mut command, spec);
8179    apply_spawn_role(&mut command, role);
8180    let nonce_handoff =
8181        apply_wire_spawn_args_for_role(&mut command, spec, connection_file_path, handle, role)?;
8182
8183    #[cfg(target_os = "linux")]
8184    let cgroup_path = cgroup_placement
8185        .map(|placement| placement.module_path(&cgroup_name))
8186        .transpose()
8187        .map_err(|source| SuperviseError::Cgroup {
8188            module_id: spec.module_id.clone(),
8189            source,
8190        })?;
8191    #[cfg(not(target_os = "linux"))]
8192    let cgroup_path: Option<PathBuf> = None;
8193    #[cfg(target_os = "linux")]
8194    if let Some(path) = &cgroup_path {
8195        if let Err(error) = apply_cgroup_placement(&mut command, spec, path) {
8196            if let Some(placement) = cgroup_placement {
8197                remove_module_cgroup(placement, &cgroup_name);
8198            }
8199            return Err(error);
8200        }
8201    }
8202
8203    let output_sink = if let Some(logs_dir) = capture_logs_dir {
8204        let path = logs_dir.join(format!("{}.stderr.log", spec.module_id));
8205        match ChildOutputSink::open(&path, capture_retention(spec)) {
8206            Ok(sink) => sink,
8207            Err(error) => {
8208                warn!(
8209                    module_id = %spec.module_id,
8210                    path = %path.display(),
8211                    error = %error,
8212                    "could not open child output capture file; forwarding to stderr"
8213                );
8214                ChildOutputSink::Stderr
8215            }
8216        }
8217    } else {
8218        ChildOutputSink::Stderr
8219    };
8220
8221    command.stdout(Stdio::piped());
8222    command.stderr(Stdio::piped());
8223    command.kill_on_drop(true);
8224    // EACH MODULE LEADS ITS OWN PROCESS GROUP (the child calls setpgid(0, 0)
8225    // before exec). In the daemon's group, a service manager that kills the
8226    // job's process group when the daemon exits (launchd's default) killed
8227    // every module at the same moment its control connection closed, so no
8228    // module ever ran its EOF teardown on a daemon stop. Outside that group a
8229    // module is reached only by the daemon: the EOF it sees when its
8230    // connection closes, and the bounded stop in `child_roster` for anything
8231    // still running after that. On Linux this composes with the cgroup
8232    // placement above: that is a pre_exec write to cgroup.procs, std performs
8233    // setpgid in the child before running pre_exec callbacks, and the two
8234    // change independent process attributes.
8235    //
8236    // stdin is /dev/null because a process outside the terminal's foreground
8237    // group is stopped (SIGTTIN) if it reads the terminal, which a daemon run
8238    // by hand would otherwise hand down. Under a service manager stdin is
8239    // already /dev/null.
8240    #[cfg(unix)]
8241    command.process_group(0);
8242    command.stdin(Stdio::null());
8243    // The LAST pre-exec step, after the cgroup placement above: installing the
8244    // nonce at descriptor 3 replaces whatever the child had there, which could
8245    // be the descriptor an earlier step writes through.
8246    #[cfg(unix)]
8247    if let Some(handoff) = nonce_handoff {
8248        handoff.install_last(command.as_std_mut());
8249    }
8250    #[cfg(not(any(unix, windows)))]
8251    let _ = nonce_handoff;
8252
8253    // Containment, step 1 of 3 (issue #109): create the child suspended so it
8254    // cannot run a single instruction -- and therefore cannot spawn a
8255    // grandchild -- before it is in the job. See `contain_spawned_child` for the
8256    // other two steps and why the window matters.
8257    #[cfg(windows)]
8258    subc_jobobject::suspend_on_create_async(&mut command);
8259    let mut child = match command.spawn() {
8260        Ok(child) => child,
8261        Err(source) => {
8262            #[cfg(target_os = "linux")]
8263            if let Some(placement) = cgroup_placement {
8264                remove_module_cgroup(placement, &cgroup_name);
8265            }
8266            return Err(SuperviseError::Spawn {
8267                program: spec.program.clone(),
8268                source,
8269                cgroup_path,
8270            });
8271        }
8272    };
8273    // The parent must close its writer now: the acknowledgement pipe reports EOF
8274    // only when every writer is gone, and the child's copy closes when the
8275    // trampoline replaces itself with the module. Command holds only an integer
8276    // in its pre_exec callback, not another writer.
8277    #[cfg(target_os = "macos")]
8278    drop(exec_ack);
8279
8280    // Bind while the child is still suspended, before job assignment resumes it.
8281    // Keeping the executable handle open across CreateProcessW denies rename
8282    // and deletion, so the file cannot be exchanged during spawn. Bind that
8283    // file ID to the process handle returned by spawn, never a reopened PID.
8284    #[cfg(windows)]
8285    let windows_image =
8286        Arc::new(
8287            image_capture
8288                .bind_tokio(&child)
8289                .map_err(|source| SuperviseError::Spawn {
8290                    program: spec.program.clone(),
8291                    source,
8292                    cgroup_path: None,
8293                })?,
8294        );
8295    // Containment, steps 2 and 3: assign while suspended, then resume.
8296    #[cfg(windows)]
8297    let job = contain_spawned_child(&child, spec)?;
8298    let spawned_at_ms = unix_ms_now();
8299    #[cfg(not(windows))]
8300    let spawned_from = spec.program.clone();
8301    #[cfg(windows)]
8302    let spawned_from = windows_image.path().to_path_buf();
8303    #[cfg(not(windows))]
8304    let spawned_file_identity = spawned_file_identity(&spawned_from);
8305    #[cfg(windows)]
8306    let spawned_file_identity = Some(SpawnedFileIdentity {
8307        device: 0,
8308        inode: 0,
8309        windows_image: Some(Arc::clone(&windows_image)),
8310    });
8311    let pid = child.id().ok_or_else(|| SuperviseError::Spawn {
8312        program: spec.program.clone(),
8313        source: io::Error::other("spawned child exposed no live pid"),
8314        cgroup_path: cgroup_path.clone(),
8315    })?;
8316    #[cfg(windows)]
8317    let nonce_delivery = start_windows_nonce_pipe(nonce_handoff, pid, spec);
8318    #[cfg(not(windows))]
8319    let process_start_time = crate::provenance::process_start_time(pid);
8320    #[cfg(windows)]
8321    let process_start_time = Some(windows_image.start_time());
8322    let process_identity = process_start_time.map(|start_time| ProcessIdentity { pid, start_time });
8323    #[cfg(all(test, target_os = "macos"))]
8324    privacy_exec_boundary_tests::before_image_sample(spec, pid);
8325    // Unix spawn returns after exec's error pipe closes. The kernel image is
8326    // therefore the executable to compare during a future orphan sweep: PATH
8327    // lookup and shebang interpretation may select a different file from the
8328    // configured program. Keep the literal program's identity for provenance,
8329    // but never use it as proof that a recorded pid may be signalled.
8330    #[cfg(not(windows))]
8331    let recorded_image = observe_spawned_image(pid);
8332    // spawn() confirms only the first exec, into the trampoline. Never persist
8333    // the trampoline image; the asynchronous acknowledgement publishes the
8334    // module image once the trampoline has replaced itself with the module.
8335    #[cfg(target_os = "macos")]
8336    let recorded_image = if privacy_exec.is_some() {
8337        None
8338    } else {
8339        recorded_image
8340    };
8341    #[cfg(target_os = "linux")]
8342    let recorded_cgroup_name = cgroup_path.as_ref().map(|_| cgroup_name.clone());
8343    #[cfg(not(target_os = "linux"))]
8344    let recorded_cgroup_name = None;
8345    let roster_guard = roster.admit(
8346        spec.module_id.clone(),
8347        pid,
8348        spec.protocol,
8349        process_start_time,
8350        crate::child_roster::RecordedIdentity {
8351            #[cfg(not(windows))]
8352            start_time: recorded_image.map(|image| image.start_time),
8353            #[cfg(windows)]
8354            start_time: process_start_time,
8355            #[cfg(not(windows))]
8356            executable: recorded_image
8357                .and_then(|image| image.executable)
8358                .map(crate::live_children::ExecutableIdentity::from),
8359            #[cfg(windows)]
8360            executable: Some(crate::live_children::ExecutableIdentity::from(
8361                windows_image.identity(),
8362            )),
8363            cgroup_name: recorded_cgroup_name,
8364            #[cfg(target_os = "linux")]
8365            cgroup_placement: cgroup_placement.cloned(),
8366        },
8367    );
8368    // The check at the top of this function can pass just before daemon
8369    // shutdown begins, and the process is only in the roster from here on.
8370    // The shutdown stop returns as soon as it finds the roster empty, so a
8371    // process admitted after that look would outlive the daemon. The roster
8372    // is closed before the stop first reads it and admission happens under
8373    // the roster's lock, so either the stop sees this process or this check
8374    // sees the roster closed: end the process now rather than start a module
8375    // the daemon is about to stop.
8376    if roster.is_closed() {
8377        // This child was never admitted, so there is no module protocol shutdown to wait for.
8378        #[cfg(target_os = "linux")]
8379        kill_module_cgroup(cgroup_placement, &cgroup_name);
8380        if let Err(error) = child.start_kill() {
8381            debug!(module_id = %spec.module_id, pid, %error, "kill of a process spawned during daemon shutdown failed; it may already have exited");
8382        }
8383        #[cfg(target_os = "linux")]
8384        if let Some(placement) = cgroup_placement {
8385            // This spawn was never admitted, so shutdown has no roster entry
8386            // to await. Do not detach its cleanup: the runtime could exit
8387            // before that task reaps the rejected child and removes its group.
8388            while matches!(child.try_wait(), Ok(None)) {
8389                std::thread::yield_now();
8390            }
8391            if matches!(
8392                subc_cgroup::kill_module(Some(placement), &cgroup_name),
8393                subc_cgroup::KillOutcome::Killed
8394            ) {
8395                if let Ok(path) = placement.module_path(&cgroup_name) {
8396                    while std::fs::read_to_string(path.join("cgroup.events"))
8397                        .ok()
8398                        .is_some_and(|events| events.lines().any(|line| line == "populated 1"))
8399                    {
8400                        std::thread::yield_now();
8401                    }
8402                }
8403            }
8404            remove_module_cgroup(placement, &cgroup_name);
8405        }
8406        drop(roster_guard);
8407        return Err(SuperviseError::Spawn {
8408            program: spec.program.clone(),
8409            source: io::Error::other(
8410                "the daemon began shutting down while this process was starting; ended it",
8411            ),
8412            cgroup_path,
8413        });
8414    }
8415
8416    let stdout_pump = match child.stdout.take() {
8417        Some(stdout) => Some(tokio::spawn(pump_stdout_to(stdout, output_sink.clone()))),
8418        None => {
8419            warn!(
8420                module_id = %spec.module_id,
8421                "spawned child exposed no stdout pipe; file capture will be incomplete"
8422            );
8423            None
8424        }
8425    };
8426    let stderr_pump = match child.stderr.take() {
8427        Some(stderr) => {
8428            let generation = ring
8429                .lock()
8430                .unwrap_or_else(|poisoned| poisoned.into_inner())
8431                .begin_process();
8432            Some(StderrPump {
8433                task: tokio::spawn(pump_stderr_to(
8434                    stderr,
8435                    Arc::clone(ring),
8436                    generation,
8437                    output_sink,
8438                )),
8439                generation,
8440            })
8441        }
8442        None => {
8443            // Spawning succeeded but the pipe did not materialise. Recording it as
8444            // uncaptured keeps the tail honest: the alternative is an empty tail
8445            // that reads as a module which printed nothing.
8446            ring.lock()
8447                .unwrap_or_else(|poisoned| poisoned.into_inner())
8448                .mark_not_captured("stderr pipe was not available on spawn");
8449            warn!(
8450                module_id = %spec.module_id,
8451                "spawned child exposed no stderr pipe; tail will be unavailable"
8452            );
8453            None
8454        }
8455    };
8456
8457    Ok(SupervisedChild {
8458        child,
8459        #[cfg(windows)]
8460        nonce_delivery,
8461        protocol: spec.protocol,
8462        #[cfg(target_os = "linux")]
8463        module_id: cgroup_name,
8464        #[cfg(target_os = "linux")]
8465        cgroup_placement: cgroup_placement.cloned(),
8466        #[cfg(windows)]
8467        job,
8468        stdout_pump,
8469        stderr_pump,
8470        stderr_ring: Arc::clone(ring),
8471        spawned_at_ms,
8472        spawned_from,
8473        spawned_file_identity,
8474        process_start_time,
8475        process_identity,
8476        pid,
8477        roster_guard: Some(roster_guard),
8478        #[cfg(target_os = "macos")]
8479        privacy_exec,
8480        #[cfg(target_os = "macos")]
8481        report_ready: Arc::new(OnceLock::new()),
8482        spawn_failure: None,
8483    })
8484}
8485
8486#[cfg(target_os = "linux")]
8487pub(crate) fn kill_module_cgroup(placement: Option<&subc_cgroup::Placement>, module_id: &str) {
8488    use subc_cgroup::KillOutcome;
8489    match subc_cgroup::kill_module(placement, module_id) {
8490        KillOutcome::Killed => {}
8491        KillOutcome::NotPlaced | KillOutcome::Unsupported => {
8492            debug!(
8493                module_id,
8494                "cgroup tree kill unavailable; using direct-child kill"
8495            );
8496        }
8497        KillOutcome::IoError { path, error } => {
8498            warn!(module_id, path = %path.display(), %error, "cgroup tree kill failed; using direct-child kill");
8499        }
8500    }
8501}
8502
8503/// Contain a freshly spawned Windows child and start it.
8504///
8505/// Steps 2 and 3 of the suspended-create contract: the job is created and the
8506/// child assigned **while it is still suspended** (step 1 is
8507/// `suspend_on_create_async` at the spawn site), then the child is resumed.
8508///
8509/// A child that is never resumed hangs forever holding a pid, so a resume
8510/// failure kills the child and fails the spawn rather than returning a
8511/// `SupervisedChild` that can never run.
8512///
8513/// An assignment failure is NOT fatal: an uncontained module behaves exactly as
8514/// it did before this existed, whereas refusing to start one would be a new
8515/// outage. It is logged at warn because it means a helper process could leak.
8516#[cfg(windows)]
8517fn contain_spawned_child(
8518    child: &Child,
8519    spec: &ModuleSpec,
8520) -> Result<Option<subc_jobobject::JobObject>, SuperviseError> {
8521    let module_id = spec.module_id.as_str();
8522    let Some(pid) = child.id() else {
8523        // The child exited between spawn and here. Its tree, if it made one,
8524        // needs no containment: nothing is left to contain.
8525        warn!(
8526            module_id,
8527            "spawned child had already exited before containment; no job object attached"
8528        );
8529        return Ok(None);
8530    };
8531
8532    let job = match subc_jobobject::JobObject::new() {
8533        Ok(job) => job,
8534        Err(source) => {
8535            warn!(
8536                module_id,
8537                error = %source,
8538                "could not create a job object; this module's helper processes will not be \
8539                 reaped on teardown"
8540            );
8541            // Resume regardless: leaving the child suspended would turn a
8542            // containment gap into a hung module.
8543            resume_suspended_child(pid, spec)?;
8544            return Ok(None);
8545        }
8546    };
8547
8548    if let Err(source) = job.assign(child) {
8549        warn!(
8550            module_id,
8551            error = %source,
8552            "could not assign the child to its job object; this module's helper processes \
8553             will not be reaped on teardown"
8554        );
8555        resume_suspended_child(pid, spec)?;
8556        return Ok(None);
8557    }
8558
8559    resume_suspended_child(pid, spec)?;
8560    Ok(Some(job))
8561}
8562
8563/// Resume a suspended child, killing it if it cannot be started.
8564///
8565/// A suspended process holds a pid and does nothing, so there is no useful
8566/// state to return: the caller gets an error and the spawn fails.
8567#[cfg(windows)]
8568fn resume_suspended_child(pid: u32, spec: &ModuleSpec) -> Result<(), SuperviseError> {
8569    if let Err(source) = subc_jobobject::resume_main_thread(pid) {
8570        // Kill it here rather than leaving a suspended process for the caller
8571        // to notice; `kill_on_drop` would eventually do this, but the module
8572        // would have been reported as running in between.
8573        let _ = std::process::Command::new("taskkill.exe")
8574            .args(["/PID", &pid.to_string(), "/T", "/F"])
8575            .stdin(Stdio::null())
8576            .stdout(Stdio::null())
8577            .stderr(Stdio::null())
8578            .status();
8579        return Err(SuperviseError::Spawn {
8580            program: spec.program.clone(),
8581            source,
8582            cgroup_path: None,
8583        });
8584    }
8585    Ok(())
8586}
8587
8588#[cfg(target_os = "linux")]
8589fn remove_module_cgroup(placement: &subc_cgroup::Placement, module_id: &str) {
8590    match placement.remove_module(module_id) {
8591        Ok(()) => debug!(module_id, "removed module cgroup after process exit"),
8592        Err(error) => warn!(
8593            module_id,
8594            error = %error,
8595            "could not remove module cgroup after process exit; continuing teardown"
8596        ),
8597    }
8598}
8599
8600#[cfg(target_os = "linux")]
8601async fn cleanup_reaped_cgroup(placement: &subc_cgroup::Placement, module_id: &str) {
8602    // Reaping the direct child is not proof its descendants exited. End the
8603    // residual tree and wait for the kernel's population fact before rmdir;
8604    // otherwise a successful parent wait leaks a directory on each restart.
8605    if matches!(
8606        subc_cgroup::kill_module(Some(placement), module_id),
8607        subc_cgroup::KillOutcome::Killed
8608    ) {
8609        if let Ok(path) = placement.module_path(module_id) {
8610            while std::fs::read_to_string(path.join("cgroup.events"))
8611                .ok()
8612                .is_some_and(|events| events.lines().any(|line| line == "populated 1"))
8613            {
8614                sleep(Duration::from_millis(1)).await;
8615            }
8616        }
8617    }
8618    remove_module_cgroup(placement, module_id);
8619}
8620
8621#[cfg(target_os = "linux")]
8622fn apply_cgroup_placement(
8623    command: &mut Command,
8624    spec: &ModuleSpec,
8625    path: &std::path::Path,
8626) -> Result<(), SuperviseError> {
8627    subc_cgroup::apply(command, path).map_err(|source| SuperviseError::Cgroup {
8628        module_id: spec.module_id.clone(),
8629        source,
8630    })
8631}
8632
8633fn capture_retention(spec: &ModuleSpec) -> Retention {
8634    let defaults = Retention::default();
8635    let value = |name: &str| {
8636        spec.env
8637            .iter()
8638            .rev()
8639            .find_map(|(key, value)| (key == name).then_some(value.as_str()))
8640    };
8641    Retention {
8642        max_file_mb: value(CAPTURE_MAX_FILE_MB_ENV)
8643            .and_then(|value| value.parse().ok())
8644            .unwrap_or(defaults.max_file_mb),
8645        keep: value(CAPTURE_KEEP_ENV)
8646            .and_then(|value| value.parse().ok())
8647            .unwrap_or(defaults.keep),
8648        max_age_days: value(CAPTURE_MAX_AGE_DAYS_ENV)
8649            .and_then(|value| value.parse().ok())
8650            .unwrap_or(defaults.max_age_days),
8651    }
8652}
8653
8654/// A fresh 256-bit CSPRNG launch nonce, lowercase hex. Used to bind a reserved
8655/// module's registration to the exact process the supervisor spawned.
8656fn generate_launch_nonce() -> Result<String, SuperviseError> {
8657    let mut bytes = [0u8; 32];
8658    getrandom::getrandom(&mut bytes).map_err(|source| SuperviseError::LaunchNonce {
8659        reason: source.to_string(),
8660    })?;
8661    let mut hex = String::with_capacity(64);
8662    for b in bytes {
8663        use std::fmt::Write;
8664        let _ = write!(hex, "{b:02x}");
8665    }
8666    Ok(hex)
8667}
8668
8669/// Constant-time byte comparison so a reserved-nonce mismatch leaks no timing
8670/// signal about how many leading bytes matched.
8671fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
8672    if a.len() != b.len() {
8673        return false;
8674    }
8675    let mut diff = 0u8;
8676    for (x, y) in a.iter().zip(b.iter()) {
8677        diff |= x ^ y;
8678    }
8679    diff == 0
8680}
8681
8682/// The kernel's image after an acknowledged exec, shared by ordinary launches
8683/// and privacy trampolines. PATH and shebang interpretation are kernel facts,
8684/// not identities inferred from a configured pathname.
8685#[cfg(not(windows))]
8686fn observe_spawned_image(pid: u32) -> Option<subc_os::Observation> {
8687    subc_os::Process::open(pid)
8688        .ok()
8689        .flatten()
8690        .and_then(|process| process.observe())
8691}
8692
8693/// Resolve once and spawn that exact path, so PATH lookup cannot choose a
8694/// different executable after the no-delete capture was taken.
8695#[cfg(windows)]
8696fn windows_spawn_capture(spec: &ModuleSpec) -> io::Result<subc_os::ExecutableCapture> {
8697    let mut program = spec.program.clone();
8698    if program.is_absolute() || program.components().count() > 1 {
8699        // An explicit path can name a PE image with no extension. Try that
8700        // exact file first, as Command does, before falling back to .exe.
8701        match subc_os::ExecutableCapture::open(&program) {
8702            Ok(capture) => return Ok(capture),
8703            Err(error)
8704                if error.kind() == io::ErrorKind::NotFound && program.extension().is_none() => {}
8705            Err(error) => return Err(error),
8706        }
8707        program.set_extension("exe");
8708        return subc_os::ExecutableCapture::open(&program);
8709    }
8710    if program.extension().is_none() {
8711        program.set_extension("exe");
8712    }
8713    let custom_path = spec
8714        .env
8715        .iter()
8716        .rev()
8717        .find(|(key, _)| key.eq_ignore_ascii_case("PATH"));
8718    let mut directories = Vec::new();
8719    // These are Windows' default executable search locations. A configured
8720    // PATH still overrides the inherited PATH, not the system directories.
8721    if let Some(parent) = std::env::current_exe()?.parent() {
8722        directories.push(parent.to_path_buf());
8723    }
8724    directories.push(std::env::current_dir()?);
8725    if let Some(windows) = std::env::var_os("SystemRoot") {
8726        let windows = PathBuf::from(windows);
8727        directories.push(windows.join("System32"));
8728        directories.push(windows.clone());
8729    }
8730    let path = custom_path
8731        .map(|(_, value)| std::ffi::OsString::from(value))
8732        .or_else(|| std::env::var_os("PATH"));
8733    if let Some(path) = path {
8734        directories.extend(std::env::split_paths(&path));
8735    }
8736    for directory in directories {
8737        match subc_os::ExecutableCapture::open(&directory.join(&program)) {
8738            Ok(capture) => return Ok(capture),
8739            Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
8740            Err(error) => return Err(error),
8741        }
8742    }
8743    Err(io::Error::new(
8744        io::ErrorKind::NotFound,
8745        "resolved executable not found",
8746    ))
8747}
8748
8749fn spawn_and_mark_running(
8750    spec: &ModuleSpec,
8751    runtime: &SupervisorRuntimeConfig,
8752    snapshot: &SharedSnapshot,
8753) -> Result<SupervisedChild, SuperviseError> {
8754    let activity_spawn = runtime.begin_activity_spawn(&spec.module_id);
8755    let child = spawn_child(
8756        spec,
8757        runtime.connection_file_path.as_deref(),
8758        runtime.supervisor_handle.as_ref(),
8759        &runtime.stderr_ring,
8760        runtime.capture_logs_dir.as_deref(),
8761        &runtime.child_roster,
8762        #[cfg(target_os = "linux")]
8763        runtime.cgroup_placement.as_ref(),
8764    )?;
8765    set_running(snapshot, &child, &spec.module_id, &runtime.spawn_events)?;
8766    if let Some(spawn) = activity_spawn {
8767        spawn.complete(snapshot);
8768    }
8769    Ok(child)
8770}
8771
8772enum RegistrationWaitOutcome {
8773    Registered,
8774    Exited(ExitReport),
8775    TimedOut,
8776}
8777
8778struct ReloadRegistrationFailure {
8779    exit_report: ExitReport,
8780    reason: String,
8781}
8782
8783#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8784enum BusyGaugeObservation {
8785    Quiescent,
8786    Busy,
8787    Omitted,
8788}
8789
8790fn busy_gauge_observation(metrics: Option<&Value>, gauges: &[String]) -> BusyGaugeObservation {
8791    let Some(metrics) = metrics.and_then(Value::as_object) else {
8792        return BusyGaugeObservation::Omitted;
8793    };
8794    let mut sum = 0u128;
8795    for gauge in gauges {
8796        let Some(value) = metrics.get(gauge) else {
8797            return BusyGaugeObservation::Omitted;
8798        };
8799        let Some(value) = value.as_u64() else {
8800            return BusyGaugeObservation::Busy;
8801        };
8802        sum = sum.saturating_add(u128::from(value));
8803    }
8804    if sum == 0 {
8805        BusyGaugeObservation::Quiescent
8806    } else {
8807        BusyGaugeObservation::Busy
8808    }
8809}
8810
8811fn declared_busy_gauges(
8812    registry: &Registry,
8813    module_id: &str,
8814) -> Result<Vec<String>, SuperviseError> {
8815    busy_gauges_of(
8816        registry
8817            .get_module(module_id)
8818            .map_err(SuperviseError::Registry)?,
8819    )
8820}
8821
8822/// [`declared_busy_gauges`] for the registration a connection holds, in any
8823/// slot: after cutover the incumbent is no longer the id's active
8824/// registration, and its own manifest is the one that names its gauges.
8825fn declared_busy_gauges_for_connection(
8826    registry: &Registry,
8827    connection_id: ConnectionId,
8828) -> Result<Vec<String>, SuperviseError> {
8829    busy_gauges_of(
8830        registry
8831            .get_module_by_connection(connection_id)
8832            .map_err(SuperviseError::Registry)?,
8833    )
8834}
8835
8836fn busy_gauges_of(
8837    registration: Option<crate::registry::ModuleRegistration>,
8838) -> Result<Vec<String>, SuperviseError> {
8839    let Some(registration) = registration else {
8840        return Ok(Vec::new());
8841    };
8842    let Some(self_signals) = registration.manifest.self_signals else {
8843        return Ok(Vec::new());
8844    };
8845
8846    let mut gauges = Vec::new();
8847    for declaration in self_signals {
8848        if declaration.kind != SelfSignalKind::Busy {
8849            continue;
8850        }
8851        match declaration.anchored_to {
8852            SignalAnchor::HealthGauges { gauges: declared } if !declared.is_empty() => {
8853                gauges.extend(declared)
8854            }
8855            _ => {
8856                // An invalid Busy anchor is fail-safe: the empty name cannot be
8857                // present in a conforming health report, so this drain stays busy.
8858                gauges.push(String::new());
8859            }
8860        }
8861    }
8862    Ok(gauges)
8863}
8864
8865/// Wait for `endpoint` to have nothing in flight and, when the module declares
8866/// busy gauges, for a health probe to report them quiet. The probe is addressed
8867/// by `scope`: a swap's superseded incumbent must be asked about its own
8868/// gauges, and by module id the probe would reach the promoted candidate.
8869async fn wait_for_forwarding_quiescence(
8870    forwarding: &ForwardingTable,
8871    module_id: &str,
8872    runtime: &SupervisorRuntimeConfig,
8873    endpoint: crate::ModuleEndpointId,
8874    deadline: Instant,
8875    busy_gauges: &[String],
8876    scope: DrainScope,
8877) -> Result<bool, SuperviseError> {
8878    let mut gauges_quiescent = busy_gauges.is_empty();
8879    let mut next_probe_at = Instant::now();
8880    let mut omission_counted = false;
8881
8882    loop {
8883        let now = Instant::now();
8884        if !busy_gauges.is_empty() && now >= next_probe_at && now < deadline {
8885            let report = match scope {
8886                DrainScope::Active => probe_module_health(module_id, runtime, Some(deadline)).await,
8887                DrainScope::Endpoint(endpoint) => {
8888                    probe_endpoint_health(endpoint, runtime, Some(deadline)).await
8889                }
8890            };
8891            gauges_quiescent = match report {
8892                Ok(report) => match busy_gauge_observation(report.metrics.as_ref(), busy_gauges) {
8893                    BusyGaugeObservation::Quiescent => true,
8894                    BusyGaugeObservation::Busy => false,
8895                    BusyGaugeObservation::Omitted => {
8896                        if !omission_counted {
8897                            forwarding
8898                                .counters()
8899                                .increment_drains_with_undeclared_gauge();
8900                            omission_counted = true;
8901                        }
8902                        false
8903                    }
8904                },
8905                Err(err) => {
8906                    warn!(
8907                        module_id,
8908                        error = %err,
8909                        "drain health.check did not produce declared busy gauges; treating module as busy"
8910                    );
8911                    false
8912                }
8913            };
8914            next_probe_at = Instant::now() + runtime.health.cadence.max(REGISTRY_RELEASE_POLL);
8915        }
8916
8917        let in_flight = forwarding
8918            .endpoint_in_flight_count(endpoint)
8919            .map_err(SuperviseError::Forwarding)?;
8920        if in_flight == 0 && gauges_quiescent {
8921            return Ok(true);
8922        }
8923
8924        let now = Instant::now();
8925        if now >= deadline {
8926            return Ok(false);
8927        }
8928        let mut wait = deadline
8929            .saturating_duration_since(now)
8930            .min(REGISTRY_RELEASE_POLL);
8931        if !busy_gauges.is_empty() {
8932            wait = wait.min(next_probe_at.saturating_duration_since(now));
8933        }
8934        sleep(wait).await;
8935    }
8936}
8937
8938/// The `route.closed` `drained` value implied by a quiescence-wait outcome.
8939///
8940/// `Ok` is always honest and passed straight through -- the wait actually measured
8941/// in-flight state. `Err` means the wait produced no measurement at all (the
8942/// forwarding table's lock was poisoned), so `false` is reported as the one honest
8943/// constant: the drain did not complete. Never recomputed from route state, never a
8944/// third "unknown" value -- the caller must still send a well-formed `route.closed`.
8945fn drained_after_quiescence_wait(wait_result: &Result<bool, SuperviseError>) -> bool {
8946    match wait_result {
8947        Ok(drained) => *drained,
8948        Err(_) => false,
8949    }
8950}
8951
8952fn send_route_goodbyes(forwarding: &ForwardingTable, released_routes: Vec<GoodbyeTarget>) {
8953    for released in released_routes {
8954        let frame = match Frame::build_with_version(
8955            released.negotiated_ver,
8956            FrameType::Goodbye,
8957            control_flags(),
8958            released.channel,
8959            released.epoch,
8960            0,
8961            Vec::new(),
8962        ) {
8963            Ok(frame) => frame,
8964            Err(err) => {
8965                warn!(
8966                    route_channel = released.channel,
8967                    error = %err,
8968                    "failed to build supervisor drain route GOODBYE frame"
8969                );
8970                continue;
8971            }
8972        };
8973        if !released.close_on_delivery_failure() {
8974            crate::forwarding::send_module_route_goodbye(
8975                &forwarding.counters(),
8976                &released.sink,
8977                frame,
8978                released.module_id.as_deref(),
8979                "supervisor drain",
8980            );
8981            continue;
8982        }
8983        if let Err(err) = released.sink.try_send(frame) {
8984            warn!(
8985                target_connection_id = released.connection_id.get(),
8986                route_channel = released.channel,
8987                error = %err,
8988                "supervisor drain route GOODBYE was not delivered to client; closing target connection"
8989            );
8990            let _ = forwarding.escalate_client_delivery_failure(
8991                released.connection_id,
8992                released.channel,
8993                released.epoch,
8994                CloseReason::new(
8995                    "route_goodbye_delivery_failed",
8996                    format!(
8997                        "failed to enqueue supervisor drain route GOODBYE for channel {}: {err}",
8998                        released.channel
8999                    ),
9000                ),
9001                crate::forwarding::UndeliveredFrame {
9002                    module_id: released.module_id.as_deref(),
9003                    sink: &released.sink,
9004                },
9005            );
9006        }
9007    }
9008}
9009
9010fn send_module_draining(
9011    module_id: &str,
9012    reason: RouteCloseReason,
9013    deadline_ms: u64,
9014    target: &ModuleDrainTarget,
9015) {
9016    let body = match serde_json::to_vec(&ModuleControlCommand::Draining {
9017        reason,
9018        deadline_ms,
9019    }) {
9020        Ok(body) => body,
9021        Err(err) => {
9022            warn!(
9023                module_id,
9024                error = %err,
9025                "failed to encode module draining command"
9026            );
9027            return;
9028        }
9029    };
9030    let frame = match Frame::build_with_version(
9031        target.negotiated_ver,
9032        FrameType::Push,
9033        control_flags(),
9034        0,
9035        0,
9036        0,
9037        body,
9038    ) {
9039        Ok(frame) => frame,
9040        Err(err) => {
9041            warn!(
9042                module_id,
9043                error = %err,
9044                "failed to build module draining command frame"
9045            );
9046            return;
9047        }
9048    };
9049    if let Err(err) = target.sink.try_send(frame) {
9050        warn!(
9051            module_id,
9052            target_connection_id = target.endpoint.connection_id.get(),
9053            error = %err,
9054            "module draining command was not delivered to peer"
9055        );
9056    }
9057}
9058
9059/// The channel-0 GOODBYE that tells a module its stop is planned.
9060fn module_goodbye_frame(module_id: &str, negotiated_ver: u8) -> Option<Frame> {
9061    match Frame::build_with_version(
9062        negotiated_ver,
9063        FrameType::Goodbye,
9064        control_flags(),
9065        0,
9066        0,
9067        0,
9068        Vec::new(),
9069    ) {
9070        Ok(frame) => Some(frame),
9071        Err(err) => {
9072            warn!(
9073                module_id,
9074                error = %err,
9075                "failed to build module GOODBYE frame"
9076            );
9077            None
9078        }
9079    }
9080}
9081
9082/// Send every registered module connection its module GOODBYE at daemon
9083/// shutdown, then request that connection's close.
9084///
9085/// A module tells a planned stop from a lost daemon by whether a GOODBYE came
9086/// before EOF, so the GOODBYE must reach the socket before the close. A close
9087/// request does not wait for the connection's queued frames: its writer gets a
9088/// bounded grace after the close, is aborted if it overruns it, and the daemon
9089/// process may exit before that grace ends. So with `wait_for_flush`, each
9090/// connection is closed only after its writer has acknowledged writing the
9091/// GOODBYE, or once a short shared budget runs out, so one module that is not
9092/// reading cannot hold up the others or the shutdown. Without it the GOODBYEs
9093/// are only queued, for a shutdown the operator has told to stop waiting.
9094/// A connection that is already gone is skipped.
9095#[cfg(unix)]
9096async fn send_module_goodbyes_for_daemon_shutdown(
9097    forwarding: &Arc<ForwardingTable>,
9098    reason: &CloseReason,
9099    wait_for_flush: bool,
9100) {
9101    const GOODBYE_BUDGET: Duration = Duration::from_millis(500);
9102    let targets = match forwarding.module_connections() {
9103        Ok(targets) => targets,
9104        Err(err) => {
9105            warn!(error = %err, "could not list module connections for shutdown GOODBYE");
9106            return;
9107        }
9108    };
9109    let deadline = Instant::now() + GOODBYE_BUDGET;
9110    let mut sends = tokio::task::JoinSet::new();
9111    for target in targets {
9112        let Some(frame) = module_goodbye_frame(&target.module_id, target.negotiated_ver) else {
9113            continue;
9114        };
9115        if !wait_for_flush {
9116            if let Err(err) = target.sink.try_send(frame) {
9117                debug!(
9118                    module_id = %target.module_id,
9119                    error = %err,
9120                    "shutdown module GOODBYE was not queued"
9121                );
9122            }
9123            continue;
9124        }
9125        let forwarding = Arc::clone(forwarding);
9126        let reason = reason.clone();
9127        sends.spawn(async move {
9128            match timeout_at(deadline, target.sink.send_flushed(frame)).await {
9129                Ok(Ok(())) => {}
9130                Ok(Err(err)) => debug!(
9131                    module_id = %target.module_id,
9132                    error = %err,
9133                    "module connection closed before its shutdown GOODBYE was written"
9134                ),
9135                Err(_) => warn!(
9136                    module_id = %target.module_id,
9137                    budget = ?GOODBYE_BUDGET,
9138                    "shutdown module GOODBYE was not written within its budget; closing anyway"
9139                ),
9140            }
9141            forwarding.request_connection_close(target.endpoint.connection_id, reason);
9142        });
9143    }
9144    // Every task ends by the shared deadline, so this wait is bounded too.
9145    while sends.join_next().await.is_some() {}
9146}
9147
9148fn send_module_goodbye(module_id: &str, forwarding: &ForwardingTable, target: &ModuleDrainTarget) {
9149    let Some(frame) = module_goodbye_frame(module_id, target.negotiated_ver) else {
9150        return;
9151    };
9152    if let Err(err) = target.sink.try_send(frame) {
9153        warn!(
9154            module_id,
9155            target_connection_id = target.endpoint.connection_id.get(),
9156            error = %err,
9157            "supervisor drain module GOODBYE was not delivered to peer; closing module connection"
9158        );
9159        forwarding.request_connection_close(
9160            target.endpoint.connection_id,
9161            CloseReason::new(
9162                "module_goodbye_delivery_failed",
9163                format!("failed to enqueue supervisor drain module GOODBYE for module '{module_id}': {err}"),
9164            ),
9165        );
9166    }
9167}
9168
9169#[derive(Clone, Copy)]
9170struct ForwardingDrainContext<'a> {
9171    spec: &'a ModuleSpec,
9172    runtime: &'a SupervisorRuntimeConfig,
9173    registry: &'a Registry,
9174    scope: DrainScope,
9175}
9176
9177/// Which process a forwarding drain addresses.
9178#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9179enum DrainScope {
9180    /// Whatever endpoint is active for the module id: every plain stop,
9181    /// restart and reload. Also moves the module's state to `Draining`.
9182    Active,
9183    /// One specific endpoint: a swap's incumbent after cutover. Draining it by
9184    /// module id would resolve to the promoted candidate and leave neither
9185    /// process routable. The module's state is left alone, since the promoted
9186    /// candidate is what it describes and that process is running.
9187    Endpoint(crate::ModuleEndpointId),
9188}
9189
9190/// Whether a child being drained has already been asked to stop by the time
9191/// its drain wait starts.
9192///
9193/// The drain wait is the same budget whatever this says. What it decides is
9194/// whether the supervisor must ask by signal before that wait begins: a child
9195/// that nobody asked will sit out the whole budget and then be SIGKILLed,
9196/// healthy or not.
9197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9198enum StopNotice {
9199    /// The module was sent `module.draining` and a module GOODBYE over its own
9200    /// registered connection, and stops itself.
9201    SentOverConnection,
9202    /// The forwarding drain found no registered connection for the module: a
9203    /// subc child spawned moments ago that has not sent HELLO yet, or a
9204    /// `protocol: "none"` child, which never registers.
9205    NoConnection,
9206    /// This path sends nothing over the module's connection: the supervisor has
9207    /// no forwarding table, or the caller stops the child without a forwarding
9208    /// drain.
9209    NotSent,
9210}
9211
9212async fn begin_forwarding_drain(
9213    spec: &ModuleSpec,
9214    runtime: &SupervisorRuntimeConfig,
9215    registry: &Registry,
9216    snapshot: &SharedSnapshot,
9217    enabled: Option<bool>,
9218    reason: RouteCloseReason,
9219) -> Result<StopNotice, SuperviseError> {
9220    let Some(forwarding) = runtime.forwarding.as_ref() else {
9221        return Err(SuperviseError::ReloadUnavailable {
9222            module_id: spec.module_id.clone(),
9223            reason: "supervisor was not configured with a forwarding table".to_string(),
9224        });
9225    };
9226
9227    begin_forwarding_drain_with(
9228        forwarding,
9229        ForwardingDrainContext {
9230            spec,
9231            runtime,
9232            registry,
9233            scope: DrainScope::Active,
9234        },
9235        snapshot,
9236        enabled,
9237        reason,
9238        runtime.drain_timeout,
9239    )
9240    .await
9241}
9242
9243async fn begin_forwarding_drain_if_configured(
9244    spec: &ModuleSpec,
9245    runtime: &SupervisorRuntimeConfig,
9246    registry: &Registry,
9247    snapshot: &SharedSnapshot,
9248    enabled: Option<bool>,
9249    reason: RouteCloseReason,
9250) -> Result<StopNotice, SuperviseError> {
9251    begin_forwarding_drain_with_timeout(
9252        spec,
9253        runtime,
9254        registry,
9255        snapshot,
9256        enabled,
9257        reason,
9258        runtime.drain_timeout,
9259    )
9260    .await
9261}
9262
9263/// Like [`begin_forwarding_drain_if_configured`] but with an explicit drain
9264/// budget, for paths where the operator overrides the module's configured one
9265/// (`supervisor.restart{drain_timeout_ms}`).
9266async fn begin_forwarding_drain_with_timeout(
9267    spec: &ModuleSpec,
9268    runtime: &SupervisorRuntimeConfig,
9269    registry: &Registry,
9270    snapshot: &SharedSnapshot,
9271    enabled: Option<bool>,
9272    reason: RouteCloseReason,
9273    drain_timeout: Duration,
9274) -> Result<StopNotice, SuperviseError> {
9275    let Some(forwarding) = runtime.forwarding.as_ref() else {
9276        return Ok(StopNotice::NotSent);
9277    };
9278
9279    begin_forwarding_drain_with(
9280        forwarding,
9281        ForwardingDrainContext {
9282            spec,
9283            runtime,
9284            registry,
9285            scope: DrainScope::Active,
9286        },
9287        snapshot,
9288        enabled,
9289        reason,
9290        drain_timeout,
9291    )
9292    .await
9293}
9294
9295async fn begin_forwarding_drain_with(
9296    forwarding: &ForwardingTable,
9297    context: ForwardingDrainContext<'_>,
9298    snapshot: &SharedSnapshot,
9299    enabled: Option<bool>,
9300    reason: RouteCloseReason,
9301    drain_timeout: Duration,
9302) -> Result<StopNotice, SuperviseError> {
9303    let ForwardingDrainContext {
9304        spec,
9305        runtime,
9306        registry,
9307        scope,
9308    } = context;
9309    debug_assert_ne!(reason, RouteCloseReason::Crash);
9310    let terminal = matches!(reason, RouteCloseReason::Disable);
9311    let drain_started_at = Instant::now();
9312    let drain_deadline = drain_started_at + drain_timeout;
9313    let deadline_ms =
9314        unix_ms_now().saturating_add(u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX));
9315    let busy_gauges = match scope {
9316        DrainScope::Active => declared_busy_gauges(registry, &spec.module_id)?,
9317        DrainScope::Endpoint(endpoint) => {
9318            declared_busy_gauges_for_connection(registry, endpoint.connection_id)?
9319        }
9320    };
9321
9322    // Admission gate first: route.open/commit and route REQUEST admission are closed
9323    // before the first quiescence check, so the outstanding count can only fall.
9324    let gate_started = Instant::now();
9325    let drain_target = match scope {
9326        DrainScope::Active => forwarding.begin_module_drain(&spec.module_id, reason),
9327        DrainScope::Endpoint(endpoint) => forwarding.begin_endpoint_drain(endpoint, reason),
9328    }
9329    .map_err(SuperviseError::Forwarding)?;
9330    // The instant admission closed, and how long taking the forwarding write
9331    // lock to close it took. The timeout line reports only the quiescence
9332    // wait, so without this a drain that started late looked like one that
9333    // started on time.
9334    info!(
9335        module_id = %spec.module_id,
9336        ?reason,
9337        gate_ms = u64::try_from(gate_started.elapsed().as_millis()).unwrap_or(u64::MAX),
9338        connected = drain_target.is_some(),
9339        "module drain began; route admission closed"
9340    );
9341    if scope == DrainScope::Active {
9342        update_snapshot(snapshot, Some(&spec.module_id), |state| {
9343            state.state = ModuleState::Draining;
9344            state.draining_to_replace =
9345                matches!(reason, RouteCloseReason::Restart | RouteCloseReason::Reload);
9346            if let Some(enabled) = enabled {
9347                state.enabled = enabled;
9348            }
9349        })?;
9350    }
9351
9352    let Some(target) = drain_target.as_ref() else {
9353        // Nothing was sent: the module has no registered connection to carry
9354        // `module.draining` or a GOODBYE. The caller must not assume the child
9355        // was asked to stop.
9356        return Ok(StopNotice::NoConnection);
9357    };
9358    {
9359        send_module_draining(&spec.module_id, reason, deadline_ms, target);
9360        let routes = forwarding
9361            .endpoint_routes(target.endpoint)
9362            .map_err(SuperviseError::Forwarding)?;
9363        let routes_notified = routes.len();
9364        crate::control::send_route_control_pushes(
9365            forwarding,
9366            routes.clone(),
9367            ClientControlPush::RouteClosing {
9368                module_id: spec.module_id.clone(),
9369                channels: Vec::new(),
9370                reason,
9371            },
9372        );
9373        send_route_goodbyes(forwarding, target.abandoned_bindings.clone());
9374
9375        // `route.closing` was just sent above: from here on every return path,
9376        // including an early one, MUST send `route.closed` before propagating
9377        // anything else. A client holds `closing` as a promise that a verdict is
9378        // coming; leaving early without `closed` strands it waiting forever, since
9379        // `closing` carries no timeout of its own.
9380        let wait_result = wait_for_forwarding_quiescence(
9381            forwarding,
9382            &spec.module_id,
9383            runtime,
9384            target.endpoint,
9385            drain_deadline,
9386            &busy_gauges,
9387            scope,
9388        )
9389        .await;
9390        let drained = drained_after_quiescence_wait(&wait_result);
9391        if let Err(err) = &wait_result {
9392            error!(
9393                module_id = %spec.module_id,
9394                ?reason,
9395                error = %err,
9396                "forwarding quiescence wait failed after route.closing; forcing route.closed(drained: false) so the client is not left waiting on an unfulfilled promise"
9397            );
9398        } else if !drained {
9399            // Name what the drain waited on. Without it the line says only that
9400            // something did not settle, and "one wedged call" and "every
9401            // session's held stream" read the same; the first is a module bug,
9402            // the second is a module that should end its streams on
9403            // module.draining. Read before teardown releases the routes.
9404            let holdouts = forwarding
9405                .endpoint_drain_holdouts(target.endpoint)
9406                .unwrap_or_default();
9407            warn!(
9408                module_id = %spec.module_id,
9409                waited = ?drain_timeout,
9410                ?reason,
9411                held_requests = holdouts.requests,
9412                held_routes = holdouts.routes,
9413                total_routes = holdouts.total_routes,
9414                top_connections = ?holdouts.top_connections,
9415                // `module_channel:corr`, so the module can find each held request
9416                // in its own log; capped, so `held_requests` is the full count.
9417                held = %holdouts
9418                    .held
9419                    .iter()
9420                    .map(|(channel, corr)| format!("{channel}:{corr}"))
9421                    .collect::<Vec<_>>()
9422                    .join(","),
9423                "route drain timed out before request quiescence; forcing teardown"
9424            );
9425        }
9426        crate::control::send_route_control_pushes(
9427            forwarding,
9428            routes,
9429            ClientControlPush::RouteClosed {
9430                module_id: spec.module_id.clone(),
9431                channels: Vec::new(),
9432                reason,
9433                drained,
9434                abandoned: target.abandoned_bindings.len() as u32,
9435                excluded_subscriptions: target.excluded_subscriptions,
9436                terminal: Some(terminal),
9437            },
9438        );
9439        wait_result?;
9440
9441        // `route.closed` has now been sent unconditionally above. From here the
9442        // remaining steps are cleanup (route + module GOODBYE) rather than a
9443        // promise the client is waiting on, but a lock-poisoned
9444        // `release_module_endpoint_routes` would otherwise skip the module
9445        // GOODBYE silently too -- send it before propagating the error.
9446        let released_routes = match forwarding.release_module_endpoint_routes(target.endpoint) {
9447            Ok(routes) => routes,
9448            Err(err) => {
9449                warn!(
9450                    module_id = %spec.module_id,
9451                    ?reason,
9452                    error = %err,
9453                    "failed to release module endpoint routes after route.closed; module GOODBYE will still be sent"
9454                );
9455                send_module_goodbye(&spec.module_id, forwarding, target);
9456                return Err(SuperviseError::Forwarding(err));
9457            }
9458        };
9459        let route_goodbye_count = released_routes.len();
9460        send_route_goodbyes(forwarding, released_routes);
9461        send_module_goodbye(&spec.module_id, forwarding, target);
9462
9463        // The drain's happy path was previously silent: every emission above is
9464        // best-effort with only its failure arm logged, so "were consumers told"
9465        // was unprovable from the daemon log (surfaced by a 30-minute consumer
9466        // hang where the open question was exactly whether teardown notice went
9467        // out). One summary line makes that class decidable in one grep.
9468        info!(
9469            module_id = %spec.module_id,
9470            ?reason,
9471            routes_notified,
9472            route_goodbyes = route_goodbye_count,
9473            abandoned_reservations = target.abandoned_bindings.len(),
9474            excluded_subscriptions = target.excluded_subscriptions,
9475            drained,
9476            "module drain complete; consumers notified via route.closing/route.closed pushes and per-route GOODBYE frames"
9477        );
9478    }
9479
9480    Ok(StopNotice::SentOverConnection)
9481}
9482
9483/// Wait for the freshly spawned child to take the ACTIVE slot for `module_id`,
9484/// the only slot a plain (non-swap) spawn can register into.
9485async fn wait_for_registration_after_reload(
9486    registry: &Registry,
9487    module_id: &str,
9488    snapshot: &SharedSnapshot,
9489    child: &mut SupervisedChild,
9490    wait: Duration,
9491) -> Result<RegistrationWaitOutcome, SuperviseError> {
9492    wait_for_slot_registration(
9493        registry,
9494        crate::registry::RegistrationSlot::Active(module_id),
9495        module_id,
9496        snapshot,
9497        child,
9498        wait,
9499    )
9500    .await
9501}
9502
9503/// Wait for `child` to register into `slot`, or to exit, or for `wait` to pass.
9504///
9505/// Keyed on the slot rather than the bare module id because during a swap the
9506/// id's active slot is already held by the incumbent: an id-keyed wait would
9507/// report the incumbent's registration as the candidate's and a candidate that
9508/// never registers would look registered. A swap candidate waits on
9509/// `crate::registry::RegistrationSlot::Candidate`.
9510async fn wait_for_slot_registration(
9511    registry: &Registry,
9512    slot: crate::registry::RegistrationSlot<'_>,
9513    module_id: &str,
9514    snapshot: &SharedSnapshot,
9515    child: &mut SupervisedChild,
9516    wait: Duration,
9517) -> Result<RegistrationWaitOutcome, SuperviseError> {
9518    let deadline = Instant::now() + wait;
9519    loop {
9520        if registry
9521            .registration(slot)
9522            .map_err(SuperviseError::Registry)?
9523            .is_some()
9524        {
9525            return Ok(RegistrationWaitOutcome::Registered);
9526        }
9527
9528        let now = Instant::now();
9529        if now >= deadline {
9530            return Ok(RegistrationWaitOutcome::TimedOut);
9531        }
9532        let remaining = deadline.saturating_duration_since(now);
9533        let poll = remaining.min(REGISTRY_RELEASE_POLL);
9534
9535        tokio::select! {
9536            wait_result = child.wait() => {
9537                let status = wait_result.map_err(|source| SuperviseError::Wait {
9538                    module_id: module_id.to_string(),
9539                    source,
9540                })?;
9541                return Ok(RegistrationWaitOutcome::Exited(classify_reaped_child_exit(
9542                    snapshot,
9543                    child,
9544                    &status,
9545                )));
9546            }
9547            _ = sleep(poll) => {}
9548        }
9549    }
9550}
9551
9552fn registration_failure_exit_report(mut exit_report: ExitReport) -> ExitReport {
9553    // A replacement process that exits before HELLO did not provide service, even
9554    // if it used status 0. Count it against the restart cap as a new-binary failure.
9555    if exit_report.kind != ExitKind::DeliberateSeverance {
9556        exit_report.kind = ExitKind::Crash;
9557    }
9558    exit_report
9559}
9560
9561async fn handle_reload_child_registration_failure(
9562    spec: &ModuleSpec,
9563    runtime: &SupervisorRuntimeConfig,
9564    registry: &Registry,
9565    process_liveness: &SupervisorProcessLiveness,
9566    snapshot: &SharedSnapshot,
9567    _child: &mut Option<SupervisedChild>,
9568    failure: ReloadRegistrationFailure,
9569) -> Result<(), SuperviseError> {
9570    let ReloadRegistrationFailure {
9571        exit_report,
9572        reason,
9573    } = failure;
9574    match on_child_exit(
9575        spec,
9576        runtime.restart_policy,
9577        registry,
9578        snapshot,
9579        &runtime.terminal_ring,
9580        &runtime.spawn_events,
9581        &runtime.child_roster,
9582        exit_report,
9583    )
9584    .await
9585    {
9586        NextAction::Stop {
9587            registration_released,
9588        } => {
9589            if registration_released {
9590                process_liveness.untrack_if_current(&spec.module_id, snapshot);
9591            }
9592        }
9593        NextAction::Restart { schedule } => {
9594            let delay = schedule.map_or(runtime.restart_policy.delay_for_restart(0), |schedule| {
9595                schedule.delay
9596            });
9597            if let Some(schedule) = schedule {
9598                log_crash_respawn(&spec.module_id, schedule);
9599            }
9600            schedule_respawn(
9601                runtime,
9602                snapshot,
9603                &spec.module_id,
9604                delay,
9605                RespawnKind::Spawn,
9606            )?;
9607        }
9608    }
9609    Err(SuperviseError::ReloadFailed {
9610        module_id: spec.module_id.clone(),
9611        reason,
9612    })
9613}
9614
9615async fn handle_reload_spawn_failure(
9616    spec: &ModuleSpec,
9617    runtime: &SupervisorRuntimeConfig,
9618    process_liveness: &SupervisorProcessLiveness,
9619    snapshot: &SharedSnapshot,
9620    _child: &mut Option<SupervisedChild>,
9621    reason: String,
9622) -> Result<(), SuperviseError> {
9623    let now = Instant::now();
9624    let mut schedule = None;
9625    update_snapshot(snapshot, Some(&spec.module_id), |state| {
9626        clear_current_process_facts(state);
9627        if state.enabled {
9628            schedule = state.next_crash_restart(&runtime.restart_policy, now);
9629            state.state = if schedule.is_some() {
9630                ModuleState::Restarting
9631            } else {
9632                ModuleState::Failed
9633            };
9634        } else {
9635            state.state = ModuleState::Disabled;
9636        }
9637    })?;
9638    if let Some(schedule) = schedule {
9639        schedule_respawn(
9640            runtime,
9641            snapshot,
9642            &spec.module_id,
9643            schedule.delay,
9644            RespawnKind::Spawn,
9645        )?;
9646    } else {
9647        process_liveness.untrack_if_current(&spec.module_id, snapshot);
9648    }
9649    Err(SuperviseError::ReloadFailed {
9650        module_id: spec.module_id.clone(),
9651        reason,
9652    })
9653}
9654
9655fn control_flags() -> Flags {
9656    Flags::new(false, Priority::Passive, false)
9657}
9658
9659#[allow(clippy::too_many_arguments)]
9660async fn drain_optional_child(
9661    module_id: &str,
9662    protocol: ModuleProtocol,
9663    stop_notice: StopNotice,
9664    registry: &Registry,
9665    forwarding: Option<&ForwardingTable>,
9666    snapshot: &SharedSnapshot,
9667    terminal_ring: &Arc<Mutex<TerminalRing>>,
9668    spawn_events: &SpawnEventFeed,
9669    child: &mut Option<SupervisedChild>,
9670    drain_timeout: Duration,
9671    final_state: ModuleState,
9672    enabled: Option<bool>,
9673) -> Result<(), SuperviseError> {
9674    if let Some(child) = child.take() {
9675        drain_child_to_state(
9676            module_id,
9677            protocol,
9678            stop_notice,
9679            registry,
9680            forwarding,
9681            snapshot,
9682            terminal_ring,
9683            spawn_events,
9684            child,
9685            drain_timeout,
9686            final_state,
9687            enabled,
9688        )
9689        .await
9690    } else {
9691        update_snapshot(snapshot, Some(module_id), |state| {
9692            state.state = final_state;
9693            if let Some(enabled) = enabled {
9694                state.enabled = enabled;
9695            }
9696            clear_current_process_facts(state);
9697        })?;
9698        release_dead_registration(registry, forwarding, snapshot, module_id).await
9699    }
9700}
9701
9702#[allow(clippy::too_many_arguments)]
9703async fn drain_child_to_state(
9704    module_id: &str,
9705    _protocol: ModuleProtocol,
9706    stop_notice: StopNotice,
9707    registry: &Registry,
9708    forwarding: Option<&ForwardingTable>,
9709    snapshot: &SharedSnapshot,
9710    terminal_ring: &Arc<Mutex<TerminalRing>>,
9711    spawn_events: &SpawnEventFeed,
9712    mut child: SupervisedChild,
9713    drain_timeout: Duration,
9714    final_state: ModuleState,
9715    enabled: Option<bool>,
9716) -> Result<(), SuperviseError> {
9717    let protocol = child.protocol;
9718    update_snapshot(snapshot, Some(module_id), |state| {
9719        state.state = ModuleState::Draining;
9720        state.draining_to_replace = final_state == ModuleState::Restarting;
9721        if let Some(enabled) = enabled {
9722            state.enabled = enabled;
9723        }
9724    })?;
9725
9726    // The wait below is the same budget in every case; what differs is
9727    // whether anything has ASKED the child to stop before it starts. Only a
9728    // forwarding drain that reached the module's registered connection has
9729    // (`module.draining`, then a module GOODBYE). Every other child was told
9730    // nothing: a `protocol: "none"` module, which never registers; a subc
9731    // module spawned moments ago that has not sent HELLO yet; or a stop that
9732    // runs no forwarding drain. Without a signal the budget is only a delay
9733    // in front of SIGKILL -- and the not-yet-registered child is the worst
9734    // case, because it registers into a module that is already draining,
9735    // is never told, and is killed while healthy.
9736    if stop_notice != StopNotice::SentOverConnection {
9737        if protocol == ModuleProtocol::Subc && stop_notice == StopNotice::NoConnection {
9738            info!(
9739                module_id,
9740                pid = child.pid,
9741                budget_ms = u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX),
9742                "module has no connection yet; requesting stop by signal"
9743            );
9744        }
9745        request_graceful_stop(module_id, &child);
9746    }
9747
9748    let exit_report = match timeout(drain_timeout, child.wait()).await {
9749        Ok(Ok(status)) => classify_reaped_child_exit(snapshot, &child, &status),
9750        Ok(Err(source)) => {
9751            fail_after_wait_error(
9752                module_id,
9753                snapshot,
9754                terminal_ring,
9755                spawn_events,
9756                format!("failed to wait during drain: {source}"),
9757            );
9758            return Err(SuperviseError::Wait {
9759                module_id: module_id.to_string(),
9760                source,
9761            });
9762        }
9763        Err(_) => {
9764            // Mirror the sibling arm above: state is already `Draining`, and an
9765            // error propagated from here would strand it there -- a state
9766            // `set_enabled(true)` cannot heal (`revive_terminal` matches only
9767            // `Failed | Stopped`), leaving an operator Restart as the only exit.
9768            // `Failed` before `?` keeps the module operator-visible and
9769            // revivable. Trigger is an ESRCH race (process exits between the
9770            // drain timeout firing and the kill) or a post-kill wait failure
9771            // (issue #34).
9772            //
9773            // Logged because the kill is otherwise visible only as signal 9 in
9774            // the terminal ring, and the budget it follows can be long enough
9775            // that consumers see a stretch of refusals with no stated cause.
9776            warn!(
9777                module_id,
9778                pid = child.pid,
9779                budget_ms = u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX),
9780                reason = ?final_state,
9781                ?stop_notice,
9782                "drain budget expired before the module exited; killing it"
9783            );
9784            child.start_kill().map_err(|source| {
9785                fail_after_wait_error(
9786                    module_id,
9787                    snapshot,
9788                    terminal_ring,
9789                    spawn_events,
9790                    format!("failed to kill during drain: {source}"),
9791                );
9792                SuperviseError::Kill {
9793                    module_id: module_id.to_string(),
9794                    source,
9795                }
9796            })?;
9797            let status = child.wait().await.map_err(|source| {
9798                fail_after_wait_error(
9799                    module_id,
9800                    snapshot,
9801                    terminal_ring,
9802                    spawn_events,
9803                    format!("failed to wait after drain kill: {source}"),
9804                );
9805                SuperviseError::Wait {
9806                    module_id: module_id.to_string(),
9807                    source,
9808                }
9809            })?;
9810            classify_reaped_child_exit(snapshot, &child, &status)
9811        }
9812    };
9813
9814    let detail = lock_snapshot(snapshot)?.drain_disposition_detail.take();
9815    publish_after_terminal_record(
9816        module_id,
9817        || {
9818            record_terminal_with_detail(
9819                module_id,
9820                terminal_ring,
9821                spawn_events,
9822                &exit_report,
9823                terminal_disposition(final_state),
9824                detail,
9825            )
9826        },
9827        || {
9828            update_snapshot(snapshot, Some(module_id), |state| {
9829                state.state = final_state;
9830                if let Some(enabled) = enabled {
9831                    state.enabled = enabled;
9832                }
9833                clear_current_process_facts(state);
9834                state.last_exit = Some(exit_report.clone());
9835                if exit_report.kind == ExitKind::DeliberateSeverance {
9836                    state.lifetime_restarts += 1;
9837                }
9838            })
9839        },
9840    )?;
9841    child.drain_stderr(module_id).await;
9842
9843    release_dead_registration(registry, forwarding, snapshot, module_id).await
9844}
9845
9846/// Ask a child that nothing else has asked to stop, by signal.
9847///
9848/// A registered subc module is asked over its own connection: the drain sends
9849/// `route.closing`/`route.closed` to its consumers, a GOODBYE per route, then a
9850/// module GOODBYE, and the module stops itself. A module that speaks no subc
9851/// wire receives none of that, and neither does a subc module that has not
9852/// registered yet, so for them the drain budget would be pure delay in front of
9853/// a SIGKILL -- and for a process with a store to flush (JetStream is the
9854/// reason `protocol: "none"` exists) a SIGKILL turns every ordinary teardown
9855/// into a recovery on the next start.
9856///
9857/// NEVER CALLED FOR A MODULE THAT WAS TOLD OVER ITS CONNECTION, and that is a
9858/// rule rather than an optimisation: that module's graceful stop is already
9859/// running by the time its child is drained, and a signal would race it.
9860///
9861/// Best-effort by construction. A child that has already exited is the ordinary
9862/// case rather than an error (the kill lands on a reaped or exiting pid), so a
9863/// failure is logged at debug and the wait-then-kill below still decides the
9864/// outcome.
9865#[cfg(unix)]
9866fn request_graceful_stop(module_id: &str, child: &SupervisedChild) {
9867    let Some(pid) = child
9868        .id()
9869        .and_then(|pid| i32::try_from(pid).ok())
9870        .and_then(rustix::process::Pid::from_raw)
9871    else {
9872        debug!(
9873            module_id,
9874            "no pid to signal for teardown; falling through to the drain wait"
9875        );
9876        return;
9877    };
9878    match rustix::process::kill_process(pid, rustix::process::Signal::TERM) {
9879        Ok(()) => debug!(
9880            module_id,
9881            "sent SIGTERM to a module nothing else asked to stop"
9882        ),
9883        Err(err) => debug!(
9884            module_id,
9885            error = %err,
9886            "SIGTERM to module failed; the drain wait and kill still apply"
9887        ),
9888    }
9889}
9890
9891/// Windows has no SIGTERM and no portable stand-in for one. The graceful stops
9892/// Windows does offer need cooperation this supervisor cannot assume: a console
9893/// control event requires sharing a console with the child, and `WM_CLOSE`
9894/// requires the child to pump a message loop. A supervised server process does
9895/// neither, so there is nothing to send and teardown is the wait followed by the
9896/// kill. Emulating a signal here would mean inventing a stop protocol, which is
9897/// the thing `protocol: "none"` exists to avoid.
9898#[cfg(not(unix))]
9899fn request_graceful_stop(module_id: &str, _child: &SupervisedChild) {
9900    debug!(
9901        module_id,
9902        "no graceful stop signal exists on this platform; teardown of a module nothing asked to stop waits, then kills"
9903    );
9904}
9905
9906fn terminal_disposition(final_state: ModuleState) -> TerminalDisposition {
9907    match final_state {
9908        ModuleState::Stopped => TerminalDisposition::Stopped,
9909        ModuleState::Disabled => TerminalDisposition::Disabled,
9910        ModuleState::Restarting => TerminalDisposition::Restarting,
9911        ModuleState::Failed => TerminalDisposition::Failed,
9912        ModuleState::Starting
9913        | ModuleState::Running
9914        | ModuleState::Unresponsive
9915        | ModuleState::Draining => {
9916            unreachable!("terminal exits only finish in terminal or restarting states")
9917        }
9918    }
9919}
9920
9921/// Release a reaped child's registration before allowing another spawn.
9922///
9923/// EOF is not a process-lifetime signal: an inherited socket can stay open
9924/// indefinitely, and serial frame dispatch can be waiting on egress instead of
9925/// reading EOF. After the normal release grace, request connection close (which
9926/// cancels both reads and dispatch), then allow one more release grace for the
9927/// connection guard's forwarding cleanup. Never evict a different connection.
9928async fn release_dead_registration(
9929    registry: &Registry,
9930    forwarding: Option<&ForwardingTable>,
9931    snapshot: &SharedSnapshot,
9932    module_id: &str,
9933) -> Result<(), SuperviseError> {
9934    let result = async {
9935        let registration = registry
9936            .get_module(module_id)
9937            .map_err(SuperviseError::Registry)?;
9938        match wait_for_registration_release(registry, module_id, REGISTRY_RELEASE_TIMEOUT).await {
9939            Ok(()) => return Ok(()),
9940            Err(SuperviseError::RegistrationStillActive { .. }) => {}
9941            Err(err) => return Err(err),
9942        }
9943        let pid = lock_snapshot(snapshot)?.reaped_pid;
9944        if let (Some(registration), Some(forwarding), Some(pid)) = (registration, forwarding, pid) {
9945            warn!(
9946                module_id,
9947                pid,
9948                connection_id = registration.connection_id.get(),
9949                "reaped module registration outlived release grace; closing dead connection"
9950            );
9951            forwarding.request_connection_close(
9952                registration.connection_id,
9953                CloseReason::new(
9954                    "supervised_process_reaped",
9955                    format!("module '{module_id}' pid {pid} exited"),
9956                ),
9957            );
9958            wait_for_slot_registration_release(
9959                registry,
9960                crate::registry::RegistrationSlot::Connection(registration.connection_id),
9961                REGISTRY_RELEASE_TIMEOUT,
9962            )
9963            .await?;
9964        }
9965        wait_for_registration_release(registry, module_id, Duration::ZERO).await
9966    }
9967    .await;
9968    if let Err(err) = &result {
9969        fail_snapshot(snapshot, Some(module_id), None);
9970        error!(module_id, error = %err, "registration release failed after child exit; module is failed and start can retry");
9971    }
9972    result
9973}
9974
9975/// Wait for the ACTIVE registration of `module_id` to go away, which is what a
9976/// plain stop or restart waits for before it spawns a replacement.
9977async fn wait_for_registration_release(
9978    registry: &Registry,
9979    module_id: &str,
9980    wait: Duration,
9981) -> Result<(), SuperviseError> {
9982    wait_for_slot_registration_release(
9983        registry,
9984        crate::registry::RegistrationSlot::Active(module_id),
9985        wait,
9986    )
9987    .await
9988}
9989
9990/// Wait for the registration in `slot` to go away.
9991///
9992/// Keyed on the slot rather than the bare module id because a successful swap
9993/// never empties the id's active slot (the promoted candidate is in it), so an
9994/// id-keyed wait for the incumbent's release would always time out. Draining a
9995/// swap's incumbent waits on `crate::registry::RegistrationSlot::Connection` with the
9996/// incumbent's connection instead.
9997async fn wait_for_slot_registration_release(
9998    registry: &Registry,
9999    slot: crate::registry::RegistrationSlot<'_>,
10000    wait: Duration,
10001) -> Result<(), SuperviseError> {
10002    let deadline = Instant::now() + wait;
10003    let mut release_events = registration_release_events().subscribe();
10004    let still_active = |registration: &crate::registry::ModuleRegistration| {
10005        SuperviseError::RegistrationStillActive {
10006            module_id: registration.manifest.module_id.clone(),
10007            waited: wait,
10008        }
10009    };
10010    loop {
10011        let _observed_generation = *release_events.borrow_and_update();
10012        let Some(registration) = registry
10013            .registration(slot)
10014            .map_err(SuperviseError::Registry)?
10015        else {
10016            return Ok(());
10017        };
10018
10019        let now = Instant::now();
10020        if now >= deadline {
10021            return Err(still_active(&registration));
10022        }
10023
10024        let remaining = deadline.saturating_duration_since(now);
10025        match timeout(remaining, release_events.changed()).await {
10026            Ok(Ok(())) | Ok(Err(_)) => {}
10027            Err(_) => return Err(still_active(&registration)),
10028        }
10029    }
10030}
10031
10032#[cfg(test)]
10033mod slot_registration_wait_tests {
10034    use super::*;
10035    use crate::registry::{ConnectionId, RegistrationSlot};
10036    use subc_protocol::manifest::ModuleManifest;
10037
10038    #[tokio::test]
10039    async fn enable_release_failure_is_failed_and_a_second_enable_retries() {
10040        let registry = Arc::new(Registry::default());
10041        let supervisor = Supervisor::new_for_test(Arc::clone(&registry), RestartPolicy::default());
10042        let runtime = supervisor.runtime_config();
10043        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::disabled()));
10044        let spec = ModuleSpec {
10045            module_id: "enable-stale-registration".to_string(),
10046            program: PathBuf::from("/missing/enable-retry-test"),
10047            args: Vec::new(),
10048            env: Vec::new(),
10049            reserved: false,
10050            reserved_prefixes: Vec::new(),
10051            protocol: ModuleProtocol::Subc,
10052            overlap: Default::default(),
10053        };
10054        let connection = ConnectionId::new(90);
10055        registry
10056            .register_with_control_ops(
10057                ModuleManifest::builder(&spec.module_id, "0.1.0").build(),
10058                1,
10059                connection,
10060                Vec::new(),
10061            )
10062            .unwrap();
10063        let mut child = None;
10064        let err = set_child_enabled(
10065            &spec,
10066            &runtime,
10067            &registry,
10068            &supervisor.process_liveness,
10069            &snapshot,
10070            &mut child,
10071            true,
10072        )
10073        .await
10074        .unwrap_err();
10075        assert!(matches!(
10076            err,
10077            SuperviseError::RegistrationStillActive { .. }
10078        ));
10079        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
10080        assert!(child.is_none());
10081        registry.deregister_connection(connection).unwrap();
10082        let err = set_child_enabled(
10083            &spec,
10084            &runtime,
10085            &registry,
10086            &supervisor.process_liveness,
10087            &snapshot,
10088            &mut child,
10089            true,
10090        )
10091        .await
10092        .unwrap_err();
10093        assert!(
10094            matches!(err, SuperviseError::Spawn { .. }),
10095            "second enable must attempt a spawn: {err}"
10096        );
10097        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
10098    }
10099
10100    const INCUMBENT: u64 = 1;
10101    const CANDIDATE: u64 = 2;
10102
10103    fn swapped_registry() -> Arc<Registry> {
10104        let registry = Arc::new(Registry::default());
10105        let manifest = ModuleManifest::builder("m", "0.1.0").build();
10106        registry
10107            .register_with_control_ops(
10108                manifest.clone(),
10109                1,
10110                ConnectionId::new(INCUMBENT),
10111                Vec::new(),
10112            )
10113            .unwrap();
10114        registry
10115            .register_candidate_with_control_ops(
10116                manifest,
10117                1,
10118                ConnectionId::new(CANDIDATE),
10119                Vec::new(),
10120            )
10121            .unwrap();
10122        registry
10123    }
10124
10125    /// After a promotion the id's active slot is held by the new process, so an
10126    /// id-keyed wait for the incumbent's release can never succeed; the
10127    /// connection-keyed wait completes as soon as the incumbent deregisters.
10128    #[tokio::test]
10129    async fn incumbent_release_is_awaited_by_connection_not_by_module_id() {
10130        let registry = swapped_registry();
10131        registry.promote_candidate("m").unwrap().unwrap();
10132
10133        assert!(matches!(
10134            wait_for_registration_release(&registry, "m", Duration::from_millis(50)).await,
10135            Err(SuperviseError::RegistrationStillActive { .. })
10136        ));
10137
10138        // Still held while the incumbent's connection has not deregistered.
10139        assert!(matches!(
10140            wait_for_slot_registration_release(
10141                &registry,
10142                RegistrationSlot::Connection(ConnectionId::new(INCUMBENT)),
10143                Duration::from_millis(50),
10144            )
10145            .await,
10146            Err(SuperviseError::RegistrationStillActive { .. })
10147        ));
10148
10149        let releaser = Arc::clone(&registry);
10150        let release = tokio::spawn(async move {
10151            sleep(Duration::from_millis(20)).await;
10152            releaser
10153                .deregister_connection(ConnectionId::new(INCUMBENT))
10154                .unwrap();
10155            notify_registration_release();
10156        });
10157        wait_for_slot_registration_release(
10158            &registry,
10159            RegistrationSlot::Connection(ConnectionId::new(INCUMBENT)),
10160            Duration::from_secs(5),
10161        )
10162        .await
10163        .expect("the incumbent's own registration is released");
10164        release.await.unwrap();
10165        assert!(registry.get_module("m").unwrap().is_some());
10166    }
10167
10168    /// The candidate slot is waited on separately from the active slot: the
10169    /// incumbent's registration neither holds up nor stands in for it.
10170    #[tokio::test]
10171    async fn candidate_slot_wait_ignores_the_incumbents_registration() {
10172        let registry = swapped_registry();
10173        assert!(matches!(
10174            wait_for_slot_registration_release(
10175                &registry,
10176                RegistrationSlot::Candidate("m"),
10177                Duration::from_millis(50),
10178            )
10179            .await,
10180            Err(SuperviseError::RegistrationStillActive { .. })
10181        ));
10182        registry
10183            .deregister_connection(ConnectionId::new(CANDIDATE))
10184            .unwrap();
10185        wait_for_slot_registration_release(
10186            &registry,
10187            RegistrationSlot::Candidate("m"),
10188            Duration::from_millis(50),
10189        )
10190        .await
10191        .expect("a candidate slot with no candidate is released");
10192        assert!(registry
10193            .registration(RegistrationSlot::Active("m"))
10194            .unwrap()
10195            .is_some());
10196    }
10197}
10198
10199fn classify_exit(status: &ExitStatus) -> ExitReport {
10200    ExitReport {
10201        kind: if status.success() {
10202            ExitKind::Clean
10203        } else {
10204            ExitKind::Crash
10205        },
10206        code: status.code(),
10207        signal: exit_signal(status),
10208        at_ms: unix_ms_now(),
10209    }
10210}
10211
10212/// The terminal record for a module whose `wait()` call itself errored (e.g. the
10213/// child was already reaped out-of-band). There is no `ExitStatus` to read a code
10214/// or signal from -- `None`/`None` is the honest shape, not a guess -- but the
10215/// disposition still must be `Failed` so the terminal ring is not silently missing
10216/// an entry, matching what `fail_snapshot` records for this same arm.
10217fn wait_error_exit_report() -> ExitReport {
10218    ExitReport {
10219        kind: ExitKind::Crash,
10220        code: None,
10221        signal: None,
10222        at_ms: unix_ms_now(),
10223    }
10224}
10225
10226#[cfg(unix)]
10227fn exit_signal(status: &ExitStatus) -> Option<i32> {
10228    use std::os::unix::process::ExitStatusExt;
10229
10230    status.signal()
10231}
10232
10233#[cfg(not(unix))]
10234fn exit_signal(_status: &ExitStatus) -> Option<i32> {
10235    None
10236}
10237
10238/// Give an operator-touched module its full crash budget back.
10239///
10240/// Named for the counter it used to zero; it now empties the in-window ring,
10241/// which is the same act. `lifetime_restarts` is untouched on purpose -- the
10242/// ledger of what happened survives every operator action.
10243fn reset_restart_count(snapshot: &SharedSnapshot, module_id: &str) -> Result<(), SuperviseError> {
10244    update_snapshot(snapshot, Some(module_id), |state| {
10245        state.clear_crash_restarts();
10246    })
10247}
10248
10249fn set_running(
10250    snapshot: &SharedSnapshot,
10251    child: &SupervisedChild,
10252    module_id: &str,
10253    spawn_events: &SpawnEventFeed,
10254) -> Result<(), SuperviseError> {
10255    let mut state = snapshot.lock().map_err(|_| SuperviseError::StatePoisoned {
10256        module_id: Some(module_id.to_string()),
10257    })?;
10258    state.spawn_generation = spawn_events.emit_spawned(module_id, child.pid, child.spawned_at_ms);
10259    if std::mem::take(&mut state.coalesced_restart_pending) {
10260        let generation = state.spawn_generation;
10261        info!(module_id, "restart already satisfied by generation {generation}; coalesced pending request completed");
10262    }
10263    state.drain_disposition_detail = None;
10264    state.spawn_failure = None;
10265    state.waiting_for_previous_pid = None;
10266    // Every caller of this is a plain spawn, which always uses the primary key;
10267    // a promoted swap candidate sets the flag itself after this returns.
10268    state.in_alternate_slot = false;
10269    state.configuration_updated_since_spawn = false;
10270    state.spawned_protocol = Some(child.protocol);
10271    state.state = ModuleState::Running;
10272    state.enabled = true;
10273    state.process_alive = true;
10274    state.pid = child.id();
10275    #[cfg(windows)]
10276    {
10277        state.pipe_consumed = child
10278            .nonce_delivery
10279            .as_ref()
10280            .map(|delivery| delivery.consumed());
10281    }
10282    #[cfg(target_os = "macos")]
10283    {
10284        state.report_ready = Some(Arc::clone(&child.report_ready));
10285    }
10286    state.spawned_at_ms = Some(child.spawned_at_ms);
10287    state.spawned_from = Some(child.spawned_from.clone());
10288    state.spawned_file_identity = child.spawned_file_identity.clone();
10289    state.process_start_time = child.process_start_time;
10290    Ok(())
10291}
10292
10293fn clear_current_process_facts(state: &mut SupervisorSnapshot) {
10294    state.waiting_for_previous_pid = None;
10295    state.process_alive = false;
10296    state.spawned_protocol = None;
10297    state.pid = None;
10298    #[cfg(windows)]
10299    {
10300        state.pipe_consumed = None;
10301    }
10302    #[cfg(target_os = "macos")]
10303    {
10304        state.report_ready = None;
10305    }
10306    state.spawned_at_ms = None;
10307    state.spawned_from = None;
10308    state.spawned_file_identity = None;
10309    state.process_start_time = None;
10310    state.deliberate_severance = None;
10311}
10312
10313#[cfg(test)]
10314fn record_deliberate_severance(
10315    snapshot: &SharedSnapshot,
10316    identity: ProcessIdentity,
10317) -> Result<(), SuperviseError> {
10318    update_snapshot(snapshot, None, |state| {
10319        state.deliberate_severance = Some(identity);
10320    })
10321}
10322
10323fn apply_deliberate_severance_marker(
10324    snapshot: &SharedSnapshot,
10325    exited_identity: Option<ProcessIdentity>,
10326    mut exit_report: ExitReport,
10327) -> ExitReport {
10328    let marker = lock_snapshot(snapshot)
10329        .ok()
10330        .and_then(|mut state| state.deliberate_severance.take());
10331    if marker.is_some() && marker == exited_identity {
10332        exit_report.kind = ExitKind::DeliberateSeverance;
10333    }
10334    exit_report
10335}
10336
10337fn classify_reaped_child_exit(
10338    snapshot: &SharedSnapshot,
10339    child: &SupervisedChild,
10340    status: &ExitStatus,
10341) -> ExitReport {
10342    let _ = update_snapshot(snapshot, None, |state| {
10343        state.reaped_pid = Some(child.pid);
10344        state.spawn_failure = child.spawn_failure.clone();
10345    });
10346    apply_deliberate_severance_marker(snapshot, child.process_identity(), classify_exit(status))
10347}
10348
10349fn fail_snapshot(
10350    snapshot: &SharedSnapshot,
10351    module_id: Option<&str>,
10352    last_exit: Option<ExitReport>,
10353) {
10354    if let Err(err) = update_snapshot(snapshot, module_id, |state| {
10355        state.state = ModuleState::Failed;
10356        clear_current_process_facts(state);
10357        if let Some(last_exit) = last_exit {
10358            state.last_exit = Some(last_exit);
10359        }
10360    }) {
10361        error!(error = %err, "failed to mark supervisor state failed");
10362    }
10363}
10364
10365fn update_snapshot(
10366    snapshot: &SharedSnapshot,
10367    module_id: Option<&str>,
10368    update: impl FnOnce(&mut SupervisorSnapshot),
10369) -> Result<(), SuperviseError> {
10370    let mut state = snapshot.lock().map_err(|_| SuperviseError::StatePoisoned {
10371        module_id: module_id.map(ToOwned::to_owned),
10372    })?;
10373    update(&mut state);
10374    Ok(())
10375}
10376
10377const SLOW_SNAPSHOT_LOCK_THRESHOLD: Duration = Duration::from_millis(250);
10378
10379fn lock_snapshot_for_control<'a>(
10380    snapshot: &'a SharedSnapshot,
10381    module_id: &str,
10382    caller: &'static str,
10383) -> Result<std::sync::MutexGuard<'a, SupervisorSnapshot>, SuperviseError> {
10384    let started_at = Instant::now();
10385    let guard = lock_snapshot(snapshot)?;
10386    let waited = started_at.elapsed();
10387    if waited >= SLOW_SNAPSHOT_LOCK_THRESHOLD {
10388        warn!(
10389            module_id = %module_id,
10390            waited_ms = waited.as_millis() as u64,
10391            caller = %caller,
10392            "slow snapshot lock"
10393        );
10394    }
10395    Ok(guard)
10396}
10397
10398fn lock_snapshot(
10399    snapshot: &SharedSnapshot,
10400) -> Result<std::sync::MutexGuard<'_, SupervisorSnapshot>, SuperviseError> {
10401    snapshot
10402        .lock()
10403        .map_err(|_| SuperviseError::StatePoisoned { module_id: None })
10404}
10405
10406#[cfg(test)]
10407mod terminal_history_tests {
10408    use std::{
10409        path::PathBuf,
10410        sync::Arc,
10411        time::{Duration, Instant},
10412    };
10413
10414    use tokio::{sync::mpsc, time::sleep};
10415
10416    use super::{
10417        apply_deliberate_severance_marker, daemon_will_restart, drain_child_to_state,
10418        drained_after_quiescence_wait, handle_reload_spawn_failure, health_restart_child,
10419        lock_snapshot, on_child_exit, record_deliberate_severance, record_wait_error_terminal,
10420        reset_restart_count, spawn_and_mark_running, update_snapshot, wait_error_exit_report,
10421        ExitKind, ExitReport, ModuleProtocol, ModuleSpec, ModuleState, NextAction, ProcessIdentity,
10422        RestartPolicy, SpawnEventKind, StopNotice, SuperviseError, SupervisedConfiguration,
10423        SupervisedModule, SupervisedModuleInner, Supervisor, SupervisorHandle,
10424        SupervisorHealthStatus, SupervisorSnapshot,
10425    };
10426    // The supervisor's clock, distinct from the `std::time::Instant` these tests
10427    // use for their own wall-clock deadlines: crash-restart instants must be on
10428    // the same clock the production code stamps them with, which is tokio's (and
10429    // is what `start_paused` tests can move).
10430    use super::Instant as ClockInstant;
10431    use crate::{
10432        registry::Registry,
10433        terminal_ring::{TerminalRing, TerminalRingConfig},
10434    };
10435    use std::sync::Mutex;
10436    use subc_control::TerminalDisposition;
10437
10438    /// See the twin in `control.rs` for why this derives the path from
10439    /// `current_exe()` and why the existence check is here: `--lib` alone does
10440    /// not build `[[bin]]` targets, and a bare spawn then fails with a raw
10441    /// `NotFound` that reads as a broken test rather than an unbuilt dependency.
10442    pub(super) fn fake_aft_stub_path() -> PathBuf {
10443        let mut path = std::env::current_exe().expect("current_exe available in tests");
10444        path.pop();
10445        path.pop();
10446        path.push(if cfg!(windows) {
10447            "fake-aft-stub.exe"
10448        } else {
10449            "fake-aft-stub"
10450        });
10451        assert!(
10452            path.exists(),
10453            "fake-aft-stub not built at {}: run `cargo test -p subc-core` (which builds \
10454             [[bin]] targets) rather than `cargo test -p subc-core --lib` (which does not)",
10455            path.display()
10456        );
10457        path
10458    }
10459
10460    #[test]
10461    fn reserved_never_spawned_refuses_every_hello() {
10462        // The canary hole: a reserved id whose module has never spawned had NO
10463        // gate entry and admitted anyone -- the reservation protected the nonce
10464        // holder, not the NAME. Now the entry is present with no legitimate
10465        // holder and refuses all comers.
10466        let supervisor = SupervisorHandle::default();
10467        supervisor.apply_identity_configuration(&ModuleSpec {
10468            module_id: "never-spawned".to_string(),
10469            program: PathBuf::from("/usr/bin/false"),
10470            args: Vec::new(),
10471            env: Vec::new(),
10472            reserved: true,
10473            reserved_prefixes: Vec::new(),
10474            protocol: ModuleProtocol::Subc,
10475            overlap: Default::default(),
10476        });
10477        assert!(
10478            supervisor
10479                .reserved_hello_rejection("never-spawned", Some("any-forged-nonce"))
10480                .is_some(),
10481            "forged nonce must refuse on a reserved never-spawned id"
10482        );
10483        assert!(
10484            supervisor
10485                .reserved_hello_rejection("never-spawned", None)
10486                .is_some(),
10487            "absent nonce must refuse on a reserved never-spawned id"
10488        );
10489        // And a real spawn nonce minted later admits exactly that nonce.
10490        supervisor.set_spawn_nonce("never-spawned", "minted".to_string());
10491        supervisor.apply_identity_configuration(&ModuleSpec {
10492            module_id: "never-spawned".to_string(),
10493            program: PathBuf::from("/usr/bin/false"),
10494            args: Vec::new(),
10495            env: Vec::new(),
10496            reserved: true,
10497            reserved_prefixes: Vec::new(),
10498            protocol: ModuleProtocol::Subc,
10499            overlap: Default::default(),
10500        });
10501        assert!(supervisor
10502            .reserved_hello_rejection("never-spawned", Some("minted"))
10503            .is_none());
10504        assert!(supervisor
10505            .reserved_hello_rejection("never-spawned", Some("forged"))
10506            .is_some());
10507    }
10508
10509    /// Put `count` crash restarts on a snapshot's ring as if they had all just
10510    /// happened, which is what "spent budget" looks like to every reader.
10511    fn seed_crash_restarts(state: &mut SupervisorSnapshot, count: u32) {
10512        let now = ClockInstant::now();
10513        for _ in 0..count {
10514            state.crash_restarts.push_back(now);
10515        }
10516    }
10517
10518    /// Age the oldest recorded restart out of `window`, standing in for the hours
10519    /// that would otherwise have to pass. Injecting the instant is the point: a
10520    /// test that slept a real window would take ten minutes and still prove less.
10521    fn age_oldest_crash_restart_out_of_window(state: &mut SupervisorSnapshot, window: Duration) {
10522        let aged = state
10523            .crash_restarts
10524            .front()
10525            .expect("a crash restart must be recorded before it can be aged")
10526            .checked_sub(window + Duration::from_secs(1))
10527            .expect("the test clock is far enough from its origin to age an instant");
10528        state.crash_restarts[0] = aged;
10529    }
10530
10531    fn snapshot_with_restarts(enabled: bool, count: u32) -> SupervisorSnapshot {
10532        let mut state = SupervisorSnapshot::new(ModuleState::Running, enabled);
10533        seed_crash_restarts(&mut state, count);
10534        state
10535    }
10536
10537    #[test]
10538    fn daemon_owned_recovery_predicate_uses_the_pre_increment_budget() {
10539        let policy = RestartPolicy::new(3, Duration::ZERO);
10540        let now = ClockInstant::now();
10541        assert!(daemon_will_restart(
10542            &mut snapshot_with_restarts(true, 2),
10543            &policy,
10544            now
10545        ));
10546        assert!(!daemon_will_restart(
10547            &mut snapshot_with_restarts(true, 3),
10548            &policy,
10549            now
10550        ));
10551        assert!(!daemon_will_restart(
10552            &mut snapshot_with_restarts(false, 0),
10553            &policy,
10554            now
10555        ));
10556    }
10557
10558    #[test]
10559    fn crash_restart_backoff_escalates_with_in_window_count() {
10560        let policy = RestartPolicy::new(4, Duration::from_millis(100))
10561            .with_max_backoff(Duration::from_secs(30));
10562        let now = ClockInstant::now();
10563        let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
10564        let schedules = (0..4)
10565            .map(|_| {
10566                state
10567                    .next_crash_restart(&policy, now)
10568                    .expect("the test policy allows four crash restarts")
10569            })
10570            .collect::<Vec<_>>();
10571
10572        assert_eq!(
10573            schedules
10574                .iter()
10575                .map(|schedule| schedule.restart_in_window)
10576                .collect::<Vec<_>>(),
10577            vec![0, 1, 2, 3]
10578        );
10579        assert_eq!(
10580            schedules
10581                .iter()
10582                .map(|schedule| schedule.delay)
10583                .collect::<Vec<_>>(),
10584            vec![
10585                Duration::from_millis(100),
10586                Duration::from_secs(1),
10587                Duration::from_secs(10),
10588                Duration::from_secs(30),
10589            ]
10590        );
10591    }
10592
10593    #[test]
10594    fn crash_restart_backoff_resets_after_ring_clear() {
10595        let policy = RestartPolicy::new(3, Duration::from_millis(100));
10596        let now = ClockInstant::now();
10597        let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
10598        assert_eq!(
10599            state.next_crash_restart(&policy, now).unwrap().delay,
10600            Duration::from_millis(100)
10601        );
10602        assert_eq!(
10603            state.next_crash_restart(&policy, now).unwrap().delay,
10604            Duration::from_secs(1)
10605        );
10606
10607        state.clear_crash_restarts();
10608        let schedule = state
10609            .next_crash_restart(&policy, now)
10610            .expect("a cleared ring must allow another restart");
10611        assert_eq!(schedule.restart_in_window, 0);
10612        assert_eq!(schedule.delay, Duration::from_millis(100));
10613    }
10614
10615    #[test]
10616    fn crash_restart_backoff_ignores_aged_restarts() {
10617        let policy = RestartPolicy::new(3, Duration::from_millis(100));
10618        let now = ClockInstant::now();
10619        let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
10620        state
10621            .next_crash_restart(&policy, now)
10622            .expect("the first restart is allowed");
10623        state
10624            .next_crash_restart(&policy, now)
10625            .expect("the second restart is allowed");
10626        state.crash_restarts[0] = now
10627            .checked_sub(policy.window + Duration::from_secs(1))
10628            .expect("the fake clock can age a restart past the window");
10629
10630        let schedule = state
10631            .next_crash_restart(&policy, now)
10632            .expect("an aged restart must release its slot");
10633        assert_eq!(schedule.restart_in_window, 1);
10634        assert_eq!(schedule.delay, Duration::from_secs(1));
10635        assert_eq!(state.crash_restarts.len(), 2);
10636    }
10637
10638    /// The budget is a rate: the same three spent restarts refuse a respawn
10639    /// while they are recent and allow one once they have aged past the window.
10640    /// Nothing about the module changed in between, which is the whole point.
10641    #[test]
10642    fn a_budget_spent_before_the_window_no_longer_refuses() {
10643        let policy = RestartPolicy::new(3, Duration::ZERO);
10644        let mut state = snapshot_with_restarts(true, 3);
10645        let now = ClockInstant::now();
10646        assert!(!daemon_will_restart(&mut state, &policy, now));
10647
10648        assert!(daemon_will_restart(
10649            &mut state,
10650            &policy,
10651            now + policy.window + Duration::from_secs(1)
10652        ));
10653        assert!(
10654            state.crash_restarts.is_empty(),
10655            "reading the budget must drop the instants that left the window"
10656        );
10657    }
10658
10659    fn module_with_recovery_snapshot(
10660        state: ModuleState,
10661        enabled: bool,
10662        restart_count: u32,
10663    ) -> SupervisedModule {
10664        let registry = Arc::new(Registry::default());
10665        let supervisor =
10666            Supervisor::new_for_test(Arc::clone(&registry), RestartPolicy::new(3, Duration::ZERO));
10667        let runtime = supervisor.runtime_config();
10668        let spec = ModuleSpec {
10669            module_id: "recovery-snapshot".to_string(),
10670            program: fake_aft_stub_path(),
10671            args: Vec::new(),
10672            env: Vec::new(),
10673            reserved: false,
10674            reserved_prefixes: Vec::new(),
10675            protocol: ModuleProtocol::Subc,
10676            overlap: Default::default(),
10677        };
10678        let mut snapshot = SupervisorSnapshot::new(state, enabled);
10679        seed_crash_restarts(&mut snapshot, restart_count);
10680        // These tests read synthetic snapshots. A real child and monitor would
10681        // race those reads by replacing the requested state during startup.
10682        let (commands, _rx) = mpsc::channel(4);
10683        SupervisedModule {
10684            inner: Arc::new(SupervisedModuleInner {
10685                module_id: spec.module_id.clone(),
10686                registry,
10687                snapshot: Arc::new(Mutex::new(snapshot)),
10688                configuration: Arc::new(Mutex::new(SupervisedConfiguration {
10689                    spec,
10690                    health: runtime.health,
10691                })),
10692                stderr_ring: runtime.stderr_ring,
10693                terminal_ring: runtime.terminal_ring,
10694                commands,
10695                monitor: Mutex::new(None),
10696                restart_policy: runtime.restart_policy,
10697                effective_drain_timeout: runtime.effective_drain_timeout,
10698                provenance_probe: supervisor.provenance_probe.clone(),
10699            }),
10700        }
10701    }
10702
10703    #[cfg(target_os = "linux")]
10704    #[tokio::test]
10705    async fn no_cgroup_placement_does_not_block_fake_aft_stub_spawn() {
10706        let supervisor =
10707            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
10708                .with_cgroup_placement(None);
10709        let result = supervisor.spawn(ModuleSpec {
10710            module_id: "no-cgroup-placement".to_string(),
10711            program: fake_aft_stub_path(),
10712            args: Vec::new(),
10713            env: Vec::new(),
10714            reserved: false,
10715            reserved_prefixes: Vec::new(),
10716            protocol: ModuleProtocol::Subc,
10717            overlap: Default::default(),
10718        });
10719
10720        assert!(
10721            result.is_ok(),
10722            "no delegation must not turn an otherwise valid spawn into a failure: {result:?}"
10723        );
10724    }
10725
10726    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10727    async fn undecided_snapshot_uses_shared_restart_predicate() {
10728        assert!(module_with_recovery_snapshot(ModuleState::Running, true, 2)
10729            .will_recover_after_connection_loss()
10730            .unwrap());
10731        assert!(
10732            !module_with_recovery_snapshot(ModuleState::Running, true, 3)
10733                .will_recover_after_connection_loss()
10734                .unwrap()
10735        );
10736    }
10737
10738    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10739    async fn restarting_snapshot_at_exhausted_budget_is_non_terminal() {
10740        assert!(
10741            module_with_recovery_snapshot(ModuleState::Restarting, true, 3)
10742                .will_recover_after_connection_loss()
10743                .unwrap()
10744        );
10745    }
10746
10747    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10748    async fn terminal_phase_snapshots_are_terminal_before_budget_exhaustion() {
10749        assert!(!module_with_recovery_snapshot(ModuleState::Failed, true, 0)
10750            .will_recover_after_connection_loss()
10751            .unwrap());
10752        assert!(
10753            !module_with_recovery_snapshot(ModuleState::Disabled, true, 0)
10754                .will_recover_after_connection_loss()
10755                .unwrap()
10756        );
10757    }
10758
10759    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10760    async fn warming_snapshot_is_limited_to_startup_phases() {
10761        for state in [
10762            ModuleState::Starting,
10763            ModuleState::Running,
10764            ModuleState::Restarting,
10765        ] {
10766            assert!(
10767                module_with_recovery_snapshot(state, true, 0)
10768                    .is_warming()
10769                    .unwrap(),
10770                "{state:?} should be warming"
10771            );
10772        }
10773        for state in [
10774            ModuleState::Unresponsive,
10775            ModuleState::Draining,
10776            ModuleState::Stopped,
10777            ModuleState::Failed,
10778            ModuleState::Disabled,
10779        ] {
10780            assert!(
10781                !module_with_recovery_snapshot(state, true, 0)
10782                    .is_warming()
10783                    .unwrap(),
10784                "{state:?} should not be warming"
10785            );
10786        }
10787    }
10788
10789    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10790    async fn terminal_history_survives_respawn_and_keeps_both_crashes_in_order() {
10791        let registry = Arc::new(Registry::default());
10792        let supervisor =
10793            Supervisor::new_for_test(Arc::clone(&registry), RestartPolicy::new(1, Duration::ZERO));
10794        let module = supervisor
10795            .spawn(ModuleSpec {
10796                module_id: "terminal-history".to_string(),
10797                program: fake_aft_stub_path(),
10798                args: Vec::new(),
10799                env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
10800                reserved: false,
10801                reserved_prefixes: Vec::new(),
10802                protocol: ModuleProtocol::Subc,
10803                overlap: Default::default(),
10804            })
10805            .unwrap();
10806
10807        let deadline = Instant::now() + Duration::from_secs(5);
10808        loop {
10809            let history = module.terminal_history();
10810            if history.entries.len() == 2 {
10811                assert_eq!(module.status().unwrap().state, ModuleState::Failed);
10812                assert_eq!(history.dropped, 0);
10813                assert_eq!(
10814                    history
10815                        .entries
10816                        .iter()
10817                        .map(|entry| entry.exit_code)
10818                        .collect::<Vec<_>>(),
10819                    vec![Some(23), Some(23)]
10820                );
10821                assert!(history.entries[0].at_ms <= history.entries[1].at_ms);
10822                return;
10823            }
10824            assert!(
10825                Instant::now() < deadline,
10826                "module did not retain two terminal exits: {history:?}"
10827            );
10828            sleep(Duration::from_millis(10)).await;
10829        }
10830    }
10831
10832    /// A disable issued while a crash respawn is still backing off must preempt
10833    /// that respawn: the operator's stop wins, the disable must not queue behind
10834    /// the backoff, and the module must never come back up afterwards.
10835    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10836    async fn disable_during_crash_backoff_cancels_pending_respawn() {
10837        let backoff = Duration::from_secs(2);
10838        let supervisor = Supervisor::new_for_test(
10839            Arc::new(Registry::default()),
10840            RestartPolicy::new(10, backoff),
10841        );
10842        let module = supervisor
10843            .spawn(ModuleSpec {
10844                module_id: "disable-during-backoff".to_string(),
10845                program: fake_aft_stub_path(),
10846                args: Vec::new(),
10847                env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
10848                reserved: false,
10849                reserved_prefixes: Vec::new(),
10850                protocol: ModuleProtocol::Subc,
10851                overlap: Default::default(),
10852            })
10853            .unwrap();
10854
10855        // Wait for the first crash to put the module into its backoff window.
10856        let deadline = Instant::now() + Duration::from_secs(5);
10857        loop {
10858            if module.status().unwrap().state == ModuleState::Restarting {
10859                break;
10860            }
10861            assert!(
10862                Instant::now() < deadline,
10863                "module never entered the crash backoff"
10864            );
10865            sleep(Duration::from_millis(10)).await;
10866        }
10867
10868        let started = Instant::now();
10869        module.set_enabled(false).await.unwrap();
10870        let waited = started.elapsed();
10871
10872        assert!(
10873            waited < backoff / 2,
10874            "disable waited {waited:?} behind the {backoff:?} crash backoff; the operator command must preempt the pending respawn"
10875        );
10876        assert_eq!(module.status().unwrap().state, ModuleState::Disabled);
10877
10878        // Outlast the backoff: the respawn it was counting down to must never run.
10879        sleep(backoff + Duration::from_millis(500)).await;
10880        let status = module.status().unwrap();
10881        assert_eq!(status.state, ModuleState::Disabled);
10882        assert_eq!(
10883            status.spawn_generation, 1,
10884            "module respawned after the operator disabled it"
10885        );
10886    }
10887
10888    /// A `protocol: "none"` child that stays parked and exits 0 on SIGTERM:
10889    /// the shape of nats-server, the program this rule exists for.
10890    #[cfg(unix)]
10891    fn protocol_none_sigterm_exits_clean_spec(
10892        module_id: &str,
10893        dir: &std::path::Path,
10894    ) -> (ModuleSpec, PathBuf, PathBuf) {
10895        let ready = dir.join("ready");
10896        let marker = dir.join("sigterm");
10897        let spec = ModuleSpec {
10898            module_id: module_id.to_string(),
10899            program: fake_aft_stub_path(),
10900            args: Vec::new(),
10901            env: vec![
10902                ("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string()),
10903                (
10904                    "FAKE_AFT_SIGTERM_MARKER_PATH".to_string(),
10905                    marker.display().to_string(),
10906                ),
10907                (
10908                    "FAKE_AFT_NEVER_CONNECT_READY_PATH".to_string(),
10909                    ready.display().to_string(),
10910                ),
10911            ],
10912            reserved: false,
10913            reserved_prefixes: Vec::new(),
10914            protocol: ModuleProtocol::None,
10915            overlap: Default::default(),
10916        };
10917        (spec, ready, marker)
10918    }
10919
10920    /// Wait for a file the child writes, so a signal is never sent before the
10921    /// child's SIGTERM handler is installed (the default disposition would
10922    /// kill it by signal and the exit would not be clean).
10923    #[cfg(unix)]
10924    async fn wait_for_file(path: &std::path::Path) {
10925        let deadline = Instant::now() + Duration::from_secs(10);
10926        while !path.exists() {
10927            assert!(
10928                Instant::now() < deadline,
10929                "{} never appeared",
10930                path.display()
10931            );
10932            sleep(Duration::from_millis(10)).await;
10933        }
10934    }
10935
10936    /// A protocol-none module that exits 0 because something OUTSIDE the
10937    /// supervisor sent it SIGTERM is respawned, and the terminal record carries
10938    /// the crash-path disposition rather than `stopped`.
10939    #[cfg(unix)]
10940    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10941    async fn protocol_none_unrequested_clean_exit_restarts_as_a_crash() {
10942        let dir = cortexkit_test_support::ScratchDir::new("none-unrequested-clean-exit");
10943        let (spec, ready, marker) =
10944            protocol_none_sigterm_exits_clean_spec("none-unrequested-clean-exit", dir.path());
10945        let supervisor = Supervisor::new_for_test(
10946            Arc::new(Registry::default()),
10947            RestartPolicy::new(3, Duration::ZERO),
10948        );
10949        let module = supervisor.spawn(spec).unwrap();
10950        wait_for_file(&ready).await;
10951        // The ready file proves the child installed its SIGTERM handler, not
10952        // that the supervisor has processed the privacy trampoline's exec
10953        // acknowledgement. On macOS status withholds the pid until then.
10954        let deadline = Instant::now() + Duration::from_secs(10);
10955        let first_pid = loop {
10956            let status = module.status().unwrap();
10957            if status.state == ModuleState::Running {
10958                if let Some(pid) = status.pid {
10959                    break pid;
10960                }
10961            }
10962            assert!(
10963                Instant::now() < deadline,
10964                "a running module must report its pid after exec confirmation: {status:?}"
10965            );
10966            sleep(Duration::from_millis(10)).await;
10967        };
10968
10969        rustix::process::kill_process(
10970            rustix::process::Pid::from_raw(i32::try_from(first_pid).unwrap()).unwrap(),
10971            rustix::process::Signal::TERM,
10972        )
10973        .unwrap();
10974
10975        let deadline = Instant::now() + Duration::from_secs(10);
10976        let respawned = loop {
10977            let status = module.status().unwrap();
10978            if status.state == ModuleState::Running
10979                && status.pid.is_some_and(|pid| pid != first_pid)
10980            {
10981                break status;
10982            }
10983            assert!(
10984                Instant::now() < deadline,
10985                "protocol-none module was not respawned after an unrequested clean exit: {status:?}"
10986            );
10987            sleep(Duration::from_millis(10)).await;
10988        };
10989        assert_eq!(respawned.spawn_generation, 2);
10990        assert!(
10991            marker.exists(),
10992            "the child must have exited through its SIGTERM handler (exit 0), or this proves nothing about clean exits"
10993        );
10994
10995        let history = module.terminal_history();
10996        assert_eq!(history.entries.len(), 1, "{history:?}");
10997        let entry = &history.entries[0];
10998        assert_eq!(entry.exit_code, Some(0));
10999        assert_eq!(entry.exit_kind, subc_control::TerminalExitKind::Clean);
11000        assert_eq!(entry.disposition, TerminalDisposition::Restarting);
11001
11002        module.stop().await.unwrap();
11003    }
11004
11005    /// Repeated unrequested clean exits of a protocol-none module spend the
11006    /// restart budget exactly as crashes do, and the module ends `failed` with
11007    /// the budget named.
11008    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11009    async fn protocol_none_repeated_clean_exits_exhaust_the_restart_budget() {
11010        let supervisor = Supervisor::new_for_test(
11011            Arc::new(Registry::default()),
11012            RestartPolicy::new(1, Duration::ZERO),
11013        );
11014        let module = supervisor
11015            .spawn(ModuleSpec {
11016                module_id: "none-clean-exit-budget".to_string(),
11017                program: fake_aft_stub_path(),
11018                args: Vec::new(),
11019                env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "0".to_string())],
11020                reserved: false,
11021                reserved_prefixes: Vec::new(),
11022                protocol: ModuleProtocol::None,
11023                overlap: Default::default(),
11024            })
11025            .unwrap();
11026
11027        // Failed follows the terminal write; this deadline only bounds a hang,
11028        // not an assumed duration for the two launches or their exit recording.
11029        let deadline = Instant::now() + Duration::from_secs(10);
11030        loop {
11031            let status = module.status().unwrap();
11032            if status.state == ModuleState::Failed {
11033                break;
11034            }
11035            assert!(
11036                Instant::now() < deadline,
11037                "module never exhausted its budget: {status:?} {:?}",
11038                module.terminal_history()
11039            );
11040            sleep(Duration::from_millis(10)).await;
11041        }
11042        let history = module.terminal_history();
11043        assert_eq!(
11044            history
11045                .entries
11046                .iter()
11047                .map(|entry| (entry.exit_code, entry.disposition.clone()))
11048                .collect::<Vec<_>>(),
11049            vec![
11050                (Some(0), TerminalDisposition::Restarting),
11051                (Some(0), TerminalDisposition::Failed),
11052            ]
11053        );
11054        let detail = history.entries[1]
11055            .disposition_detail
11056            .as_deref()
11057            .expect("a budget failure names the budget");
11058        assert!(detail.contains("max_restarts=1"), "{detail}");
11059        assert_eq!(module.status().unwrap().spawn_generation, 2);
11060    }
11061
11062    #[test]
11063    fn restart_budget_failure_is_published_after_its_terminal_record() {
11064        use std::io::{Read, Write};
11065        let supervisor = Supervisor::new_for_test(
11066            Arc::new(Registry::default()),
11067            RestartPolicy::new(0, Duration::ZERO),
11068        );
11069        let runtime = supervisor.runtime_config();
11070        let spec = ModuleSpec {
11071            protocol: ModuleProtocol::None,
11072            ..windowed_crash_spec("budget-publication-order")
11073        };
11074        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(
11075            ModuleState::Running,
11076            true,
11077        )));
11078        lock_snapshot(&snapshot).unwrap().process_alive = true;
11079        let reaped_snapshot = snapshot.clone();
11080        let mut barrier = terminal_record_barrier(&runtime.spawn_events);
11081        let history = runtime.terminal_ring.clone();
11082        let reap = std::thread::spawn(move || {
11083            tokio::runtime::Builder::new_current_thread()
11084                .enable_all()
11085                .build()
11086                .unwrap()
11087                .block_on(on_child_exit(
11088                    &spec,
11089                    runtime.restart_policy,
11090                    &supervisor.registry,
11091                    &reaped_snapshot,
11092                    &runtime.terminal_ring,
11093                    &runtime.spawn_events,
11094                    &runtime.child_roster,
11095                    ExitReport {
11096                        kind: ExitKind::Clean,
11097                        code: Some(0),
11098                        signal: None,
11099                        at_ms: 1,
11100                    },
11101                ))
11102        });
11103        let mut reached = [0];
11104        barrier.read_exact(&mut reached).unwrap();
11105        assert_eq!(&reached, b"R");
11106        let (before_state, before_alive) = {
11107            let state = lock_snapshot(&snapshot).unwrap();
11108            (state.state, state.process_alive)
11109        };
11110        let before_history = history.lock().unwrap().snapshot();
11111        barrier.write_all(b"X").unwrap();
11112        assert!(matches!(reap.join().unwrap(), NextAction::Stop { .. }));
11113        assert!(before_history.entries.is_empty());
11114        assert_ne!(
11115            before_state,
11116            ModuleState::Failed,
11117            "Failed was visible before its terminal record could be written"
11118        );
11119        assert!(before_alive);
11120        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
11121        let history = history.lock().unwrap().snapshot();
11122        assert_eq!(history.entries.len(), 1);
11123        assert_eq!(history.entries[0].exit_code, Some(0));
11124        assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
11125    }
11126
11127    #[test]
11128    fn crash_restart_at_budget_limit_remains_recoverable_while_recording() {
11129        use std::io::{Read, Write};
11130
11131        let supervisor = Supervisor::new_for_test(
11132            Arc::new(Registry::default()),
11133            RestartPolicy::new(3, Duration::ZERO),
11134        );
11135        let mut runtime = supervisor.runtime_config();
11136        let module = module_with_recovery_snapshot(ModuleState::Running, true, 2);
11137        let snapshot = module.inner.snapshot.clone();
11138        lock_snapshot(&snapshot).unwrap().process_alive = true;
11139        runtime.terminal_ring = module.inner.terminal_ring.clone();
11140        let spec = windowed_crash_spec(module.module_id());
11141        let mut barrier = terminal_record_barrier(&runtime.spawn_events);
11142        let reaped_snapshot = snapshot.clone();
11143        let reap = std::thread::spawn(move || {
11144            tokio::runtime::Builder::new_current_thread()
11145                .enable_all()
11146                .build()
11147                .unwrap()
11148                .block_on(on_child_exit(
11149                    &spec,
11150                    runtime.restart_policy,
11151                    &supervisor.registry,
11152                    &reaped_snapshot,
11153                    &runtime.terminal_ring,
11154                    &runtime.spawn_events,
11155                    &runtime.child_roster,
11156                    ExitReport {
11157                        kind: ExitKind::Crash,
11158                        code: Some(2),
11159                        signal: None,
11160                        at_ms: 1,
11161                    },
11162                ))
11163        });
11164
11165        // Hold the crash handler between its budget decision and publication.
11166        // Connection cleanup must still see the recovery that this crash chose,
11167        // even when that recovery consumes the last available restart slot.
11168        let mut reached = [0];
11169        barrier.read_exact(&mut reached).unwrap();
11170        assert_eq!(&reached, b"R");
11171        let before_status = module.status().unwrap();
11172        let before_history = module.terminal_history();
11173        let before_recovers = module.will_recover_after_connection_loss().unwrap();
11174        barrier.write_all(b"X").unwrap();
11175        assert!(matches!(reap.join().unwrap(), NextAction::Restart { .. }));
11176
11177        assert_eq!(before_status.state, ModuleState::Running);
11178        assert!(before_status.process_alive);
11179        assert_eq!(before_status.restart_count, 3);
11180        assert!(before_history.entries.is_empty());
11181        assert!(
11182            before_recovers,
11183            "connection cleanup would report a terminal close for a scheduled crash restart"
11184        );
11185        let restarting = module.status().unwrap();
11186        assert_eq!(restarting.state, ModuleState::Restarting);
11187        assert_eq!(restarting.restart_count, 3);
11188        assert!(module.will_recover_after_connection_loss().unwrap());
11189        let history = module.terminal_history();
11190        assert_eq!(history.entries.len(), 1);
11191        assert_eq!(
11192            history.entries[0].disposition,
11193            TerminalDisposition::Restarting
11194        );
11195
11196        // Once the replacement is running, its next crash must be judged
11197        // against the exhausted budget rather than the previous recovery.
11198        update_snapshot(&snapshot, Some(module.module_id()), |state| {
11199            state.state = ModuleState::Running;
11200            state.respawn_pending = false;
11201        })
11202        .unwrap();
11203        assert!(!module.will_recover_after_connection_loss().unwrap());
11204        update_snapshot(&snapshot, Some(module.module_id()), |state| {
11205            state.enabled = false;
11206            state.respawn_pending = true;
11207        })
11208        .unwrap();
11209        assert!(!module.will_recover_after_connection_loss().unwrap());
11210    }
11211
11212    fn terminal_record_barrier(events: &super::SpawnEventFeed) -> std::net::TcpStream {
11213        use std::net::{TcpListener, TcpStream};
11214        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
11215        let writer = TcpStream::connect(listener.local_addr().unwrap()).unwrap();
11216        let (barrier, _) = listener.accept().unwrap();
11217        for stream in [&writer, &barrier] {
11218            stream
11219                .set_read_timeout(Some(Duration::from_secs(10)))
11220                .unwrap();
11221            stream
11222                .set_write_timeout(Some(Duration::from_secs(10)))
11223                .unwrap();
11224        }
11225        *events.1.lock().unwrap() = Some(writer);
11226        barrier
11227    }
11228
11229    pub(super) fn before_terminal_record(events: &super::SpawnEventFeed) {
11230        use std::io::{Read, Write};
11231        let barrier = events.1.lock().unwrap().take();
11232        if let Some(mut barrier) = barrier {
11233            barrier.write_all(b"R").unwrap();
11234            let mut release = [0];
11235            barrier.read_exact(&mut release).unwrap();
11236            assert_eq!(&release, b"X");
11237        }
11238    }
11239
11240    #[test]
11241    fn health_restart_budget_failure_is_published_after_its_terminal_record() {
11242        use std::io::{Read, Write};
11243
11244        let supervisor = Supervisor::new_for_test(
11245            Arc::new(Registry::default()),
11246            RestartPolicy::new(0, Duration::ZERO),
11247        );
11248        let mut runtime = supervisor.runtime_config();
11249        let module = module_with_recovery_snapshot(ModuleState::Running, true, 0);
11250        let snapshot = module.inner.snapshot.clone();
11251        runtime.terminal_ring = module.inner.terminal_ring.clone();
11252        let spec = ModuleSpec {
11253            module_id: module.module_id().into(),
11254            program: fake_aft_stub_path(),
11255            protocol: ModuleProtocol::None,
11256            env: vec![("FAKE_AFT_EXIT_CODE".into(), "17".into())],
11257            ..windowed_crash_spec(module.module_id())
11258        };
11259        let mut barrier = terminal_record_barrier(&runtime.spawn_events);
11260        let reap = std::thread::spawn(move || {
11261            tokio::runtime::Builder::new_current_thread()
11262                .enable_all()
11263                .build()
11264                .unwrap()
11265                .block_on(async {
11266                    let mut child =
11267                        Some(spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap());
11268                    health_restart_child(
11269                        &spec,
11270                        &runtime,
11271                        &supervisor.registry,
11272                        &supervisor.process_liveness,
11273                        &snapshot,
11274                        &mut child,
11275                        SupervisorHealthStatus::Failing,
11276                        Some("health probe did not answer"),
11277                        1,
11278                    )
11279                    .await
11280                    .unwrap();
11281                    assert!(child.is_none());
11282                });
11283        });
11284        // The loopback barrier acknowledges entry into terminal recording by
11285        // health_restart_child's drain, then waits for the test's release byte.
11286        // Status and history are observed while that writer is held, not after
11287        // an elapsed-time guess about when the health action has completed.
11288        let mut reached = [0];
11289        barrier.read_exact(&mut reached).unwrap();
11290        assert_eq!(&reached, b"R");
11291        let before_status = module.status().unwrap();
11292        let before_history = module.terminal_history();
11293        barrier.write_all(b"X").unwrap();
11294        reap.join().unwrap();
11295        assert!(before_history.entries.is_empty());
11296        assert_ne!(
11297            before_status.state,
11298            ModuleState::Failed,
11299            "Failed was visible before its terminal record could be written"
11300        );
11301        assert!(
11302            before_status.process_alive,
11303            "process death was visible before its terminal record could be written"
11304        );
11305        let failed = module.status().unwrap();
11306        assert_eq!(failed.state, ModuleState::Failed);
11307        assert!(!failed.process_alive);
11308        let history = module.terminal_history();
11309        assert_eq!(history.entries.len(), 1);
11310        assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
11311        assert_eq!(
11312            history.entries[0].disposition_detail.as_deref(),
11313            Some("crash budget exhausted: max_restarts=0 within window_secs=600")
11314        );
11315    }
11316
11317    #[test]
11318    fn observed_exits_publish_process_death_after_terminal_history() {
11319        use std::io::{Read, Write};
11320        for (kind, enabled, shutdown, wait_error, expected_state) in [
11321            (ExitKind::Clean, true, false, false, ModuleState::Stopped),
11322            (ExitKind::Crash, true, false, false, ModuleState::Restarting),
11323            (ExitKind::Crash, false, false, false, ModuleState::Disabled),
11324            (
11325                ExitKind::DeliberateSeverance,
11326                true,
11327                false,
11328                false,
11329                ModuleState::Restarting,
11330            ),
11331            #[cfg(unix)]
11332            (ExitKind::Crash, true, true, false, ModuleState::Stopped),
11333            (ExitKind::Crash, true, false, true, ModuleState::Failed),
11334        ] {
11335            let supervisor = Supervisor::new_for_test(
11336                Arc::new(Registry::default()),
11337                RestartPolicy::new(1, Duration::ZERO),
11338            );
11339            let runtime = supervisor.runtime_config();
11340            #[cfg(unix)]
11341            if shutdown {
11342                runtime.child_roster.close();
11343            }
11344            #[cfg(not(unix))]
11345            let _ = shutdown;
11346            let spec = windowed_crash_spec("exit-publication-order");
11347            let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(
11348                ModuleState::Running,
11349                enabled,
11350            )));
11351            lock_snapshot(&snapshot).unwrap().process_alive = true;
11352            let reaped_snapshot = snapshot.clone();
11353            let history = runtime.terminal_ring.clone();
11354            let mut barrier = terminal_record_barrier(&runtime.spawn_events);
11355            let reap = std::thread::spawn(move || {
11356                if wait_error {
11357                    super::fail_after_wait_error(
11358                        &spec.module_id,
11359                        &reaped_snapshot,
11360                        &runtime.terminal_ring,
11361                        &runtime.spawn_events,
11362                        "wait failed".into(),
11363                    );
11364                } else {
11365                    tokio::runtime::Builder::new_current_thread()
11366                        .enable_all()
11367                        .build()
11368                        .unwrap()
11369                        .block_on(on_child_exit(
11370                            &spec,
11371                            runtime.restart_policy,
11372                            &supervisor.registry,
11373                            &reaped_snapshot,
11374                            &runtime.terminal_ring,
11375                            &runtime.spawn_events,
11376                            &runtime.child_roster,
11377                            ExitReport {
11378                                kind,
11379                                code: Some(0),
11380                                signal: None,
11381                                at_ms: 1,
11382                            },
11383                        ));
11384                }
11385            });
11386            let mut reached = [0];
11387            barrier.read_exact(&mut reached).unwrap();
11388            assert_eq!(&reached, b"R");
11389            let before_alive = lock_snapshot(&snapshot).unwrap().process_alive;
11390            let before_history = history.lock().unwrap().snapshot();
11391            barrier.write_all(b"X").unwrap();
11392            reap.join().unwrap();
11393            assert!(
11394                before_alive,
11395                "{kind:?}: death was published before recording"
11396            );
11397            assert!(before_history.entries.is_empty());
11398            assert_eq!(lock_snapshot(&snapshot).unwrap().state, expected_state);
11399            assert!(!lock_snapshot(&snapshot).unwrap().process_alive);
11400            assert_eq!(history.lock().unwrap().snapshot().entries.len(), 1);
11401        }
11402    }
11403
11404    #[tokio::test]
11405    async fn health_restart_budget_failure_remains_failed_when_journal_append_fails() {
11406        let dir = cortexkit_test_support::ScratchDir::new("health-budget-journal-failure");
11407        let path = dir.join("terminals.jsonl");
11408        std::fs::create_dir(&path).unwrap();
11409        let supervisor = Supervisor::new_for_test(
11410            Arc::new(Registry::default()),
11411            RestartPolicy::new(0, Duration::ZERO),
11412        )
11413        .with_terminal_journal(path, "health-budget-journal-failure".into());
11414        let runtime = supervisor.runtime_config();
11415        let spec = ModuleSpec {
11416            program: fake_aft_stub_path(),
11417            protocol: ModuleProtocol::None,
11418            env: vec![("FAKE_AFT_EXIT_CODE".into(), "17".into())],
11419            ..windowed_crash_spec("health-budget-journal-failure")
11420        };
11421        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11422        let mut child = Some(spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap());
11423        health_restart_child(
11424            &spec,
11425            &runtime,
11426            &supervisor.registry,
11427            &supervisor.process_liveness,
11428            &snapshot,
11429            &mut child,
11430            SupervisorHealthStatus::Failing,
11431            None,
11432            1,
11433        )
11434        .await
11435        .unwrap();
11436        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
11437        assert!(!lock_snapshot(&snapshot).unwrap().process_alive);
11438        let history = runtime
11439            .terminal_ring
11440            .lock()
11441            .unwrap()
11442            .durable_history(&spec.module_id);
11443        assert!(history.journal_write_failures > 0);
11444        assert_eq!(history.entries.len(), 1);
11445        assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
11446    }
11447
11448    #[test]
11449    fn health_restart_budget_failure_remains_failed_when_exit_recording_panics() {
11450        let supervisor = Supervisor::new_for_test(
11451            Arc::new(Registry::default()),
11452            RestartPolicy::new(0, Duration::ZERO),
11453        );
11454        let runtime = supervisor.runtime_config();
11455        let spec = ModuleSpec {
11456            program: fake_aft_stub_path(),
11457            protocol: ModuleProtocol::None,
11458            env: vec![("FAKE_AFT_EXIT_CODE".into(), "17".into())],
11459            ..windowed_crash_spec("health-budget-recording-panic")
11460        };
11461        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11462        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
11463            tokio::runtime::Builder::new_current_thread()
11464                .enable_all()
11465                .build()
11466                .unwrap()
11467                .block_on(async {
11468                    let mut child =
11469                        Some(spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap());
11470                    runtime.spawn_events.0.lock().unwrap().seq = u64::MAX;
11471                    health_restart_child(
11472                        &spec,
11473                        &runtime,
11474                        &supervisor.registry,
11475                        &supervisor.process_liveness,
11476                        &snapshot,
11477                        &mut child,
11478                        SupervisorHealthStatus::Failing,
11479                        None,
11480                        1,
11481                    )
11482                    .await
11483                    .unwrap();
11484                });
11485        }));
11486        let panic = result.expect_err("recording must still unwind");
11487        assert_eq!(
11488            panic.downcast_ref::<String>().map(String::as_str),
11489            Some("spawn event sequence exhausted")
11490        );
11491        let state = lock_snapshot(&snapshot).unwrap();
11492        assert_eq!(state.state, ModuleState::Failed);
11493        assert!(!state.process_alive);
11494        assert!(state.last_exit.is_some());
11495    }
11496
11497    #[tokio::test]
11498    async fn restart_budget_failure_remains_failed_when_journal_append_fails() {
11499        let dir = cortexkit_test_support::ScratchDir::new("budget-journal-failure");
11500        let path = dir.join("terminals.jsonl");
11501        std::fs::create_dir(&path).unwrap();
11502        let supervisor = Supervisor::new_for_test(
11503            Arc::new(Registry::default()),
11504            RestartPolicy::new(0, Duration::ZERO),
11505        )
11506        .with_terminal_journal(path, "budget-journal-failure".into());
11507        let runtime = supervisor.runtime_config();
11508        let spec = windowed_crash_spec("budget-journal-failure");
11509        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11510        assert!(matches!(
11511            on_child_exit(
11512                &spec,
11513                runtime.restart_policy,
11514                &supervisor.registry,
11515                &snapshot,
11516                &runtime.terminal_ring,
11517                &runtime.spawn_events,
11518                &runtime.child_roster,
11519                crash_exit_report(1),
11520            )
11521            .await,
11522            NextAction::Stop { .. }
11523        ));
11524        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
11525        let history = runtime
11526            .terminal_ring
11527            .lock()
11528            .unwrap()
11529            .durable_history(&spec.module_id);
11530        assert!(history.journal_write_failures > 0);
11531        assert_eq!(history.entries.len(), 1);
11532        assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
11533    }
11534
11535    #[test]
11536    fn restart_budget_failure_remains_failed_when_exit_recording_panics() {
11537        let supervisor = Supervisor::new_for_test(
11538            Arc::new(Registry::default()),
11539            RestartPolicy::new(0, Duration::ZERO),
11540        );
11541        let runtime = supervisor.runtime_config();
11542        let spec = windowed_crash_spec("budget-recording-panic");
11543        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11544        runtime.spawn_events.emit_spawned(&spec.module_id, 1, 1);
11545        // Exhausting the event sequence makes emit_exited panic before the
11546        // terminal write, exercising publication on the recording unwind.
11547        runtime.spawn_events.0.lock().unwrap().seq = u64::MAX;
11548        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
11549            tokio::runtime::Builder::new_current_thread()
11550                .enable_all()
11551                .build()
11552                .unwrap()
11553                .block_on(on_child_exit(
11554                    &spec,
11555                    runtime.restart_policy,
11556                    &supervisor.registry,
11557                    &snapshot,
11558                    &runtime.terminal_ring,
11559                    &runtime.spawn_events,
11560                    &runtime.child_roster,
11561                    crash_exit_report(1),
11562                ))
11563        }));
11564        let panic = result.err().expect("recording must still unwind");
11565        assert_eq!(
11566            panic.downcast_ref::<String>().map(String::as_str),
11567            Some("spawn event sequence exhausted")
11568        );
11569        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
11570    }
11571
11572    /// A stop the supervisor itself requests still stops a protocol-none
11573    /// module, even though the child answers the SIGTERM with exit 0.
11574    #[cfg(unix)]
11575    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11576    async fn protocol_none_requested_stop_and_disable_do_not_respawn() {
11577        for disable in [false, true] {
11578            let label = if disable {
11579                "none-requested-disable"
11580            } else {
11581                "none-requested-stop"
11582            };
11583            let dir = cortexkit_test_support::ScratchDir::new(label);
11584            let (spec, ready, marker) = protocol_none_sigterm_exits_clean_spec(label, dir.path());
11585            let supervisor = Supervisor::new_for_test(
11586                Arc::new(Registry::default()),
11587                RestartPolicy::new(3, Duration::ZERO),
11588            );
11589            let module = supervisor.spawn(spec).unwrap();
11590            wait_for_file(&ready).await;
11591
11592            if disable {
11593                module.set_enabled(false).await.unwrap();
11594            } else {
11595                module.stop().await.unwrap();
11596            }
11597            assert!(
11598                marker.exists(),
11599                "{label}: the child must have left through its SIGTERM handler with exit 0"
11600            );
11601
11602            // Long enough for a zero-backoff respawn to have happened if the
11603            // exit had been treated as a crash.
11604            sleep(Duration::from_millis(500)).await;
11605            let status = module.status().unwrap();
11606            let expected = if disable {
11607                ModuleState::Disabled
11608            } else {
11609                ModuleState::Stopped
11610            };
11611            assert_eq!(status.state, expected, "{label}");
11612            assert_eq!(
11613                status.spawn_generation, 1,
11614                "{label}: respawned after a requested stop"
11615            );
11616            let history = module.terminal_history();
11617            assert_eq!(history.entries.len(), 1, "{label}: {history:?}");
11618            assert_eq!(history.entries[0].exit_code, Some(0), "{label}");
11619            assert_ne!(
11620                history.entries[0].disposition,
11621                TerminalDisposition::Restarting,
11622                "{label}"
11623            );
11624        }
11625    }
11626
11627    /// A subc-wire module that exits 0 on its own is still a stop: the
11628    /// protocol-none rule must not reach it.
11629    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11630    async fn subc_wire_clean_exit_is_still_a_stop() {
11631        let supervisor = Supervisor::new_for_test(
11632            Arc::new(Registry::default()),
11633            RestartPolicy::new(3, Duration::ZERO),
11634        );
11635        let module = supervisor
11636            .spawn(ModuleSpec {
11637                module_id: "wire-clean-exit".to_string(),
11638                program: fake_aft_stub_path(),
11639                args: Vec::new(),
11640                env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "0".to_string())],
11641                reserved: false,
11642                reserved_prefixes: Vec::new(),
11643                protocol: ModuleProtocol::Subc,
11644                overlap: Default::default(),
11645            })
11646            .unwrap();
11647
11648        let deadline = Instant::now() + Duration::from_secs(10);
11649        while module.terminal_history().entries.is_empty() {
11650            assert!(Instant::now() < deadline, "module never exited");
11651            sleep(Duration::from_millis(10)).await;
11652        }
11653        // Long enough for a zero-backoff respawn to have happened.
11654        sleep(Duration::from_millis(500)).await;
11655        let status = module.status().unwrap();
11656        assert_eq!(status.state, ModuleState::Stopped);
11657        assert_eq!(status.spawn_generation, 1);
11658        let history = module.terminal_history();
11659        assert_eq!(history.entries.len(), 1, "{history:?}");
11660        assert_eq!(history.entries[0].exit_code, Some(0));
11661        assert_eq!(history.entries[0].disposition, TerminalDisposition::Stopped);
11662    }
11663
11664    #[cfg(unix)]
11665    #[tokio::test]
11666    async fn reload_exit_keeps_roster_until_terminal_is_recorded_during_shutdown() {
11667        let dir = cortexkit_test_support::ScratchDir::new("reload-roster-terminal-order");
11668        let record = dir.join("live-children.json");
11669        let supervisor = Supervisor::new_for_test(
11670            Arc::new(Registry::default()),
11671            RestartPolicy::new(0, Duration::ZERO),
11672        );
11673        let mut runtime = supervisor.runtime_config();
11674        runtime.child_roster.record_to(record.clone());
11675        let gate = Arc::new(super::ReloadExitRecordGate::default());
11676        runtime.test_reload_exit_record_gate = Some(Arc::clone(&gate));
11677        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11678        let spec = ModuleSpec {
11679            module_id: "reload-exit-roster".into(),
11680            program: fake_aft_stub_path(),
11681            args: Vec::new(),
11682            env: vec![("FAKE_AFT_EXIT_CODE".into(), "23".into())],
11683            reserved: false,
11684            reserved_prefixes: Vec::new(),
11685            protocol: ModuleProtocol::Subc,
11686            overlap: Default::default(),
11687        };
11688        let mut child = None;
11689        let reload = super::finish_reload_child(
11690            &spec,
11691            &runtime,
11692            &supervisor.registry,
11693            &supervisor.process_liveness,
11694            &snapshot,
11695            &mut child,
11696        );
11697        tokio::pin!(reload);
11698        tokio::select! {
11699            result = &mut reload => panic!("reload missed the before-record gate: {result:?}"),
11700            _ = gate.reached.notified() => {}
11701        }
11702        assert!(runtime
11703            .terminal_ring
11704            .lock()
11705            .unwrap()
11706            .snapshot()
11707            .entries
11708            .is_empty());
11709        assert_eq!(
11710            crate::live_children::read_record(&record).unwrap().len(),
11711            1,
11712            "shutdown must still wait for the reaped child until its terminal record exists"
11713        );
11714        runtime.child_roster.close();
11715        gate.resume.notify_one();
11716        assert!(reload.await.is_err());
11717        assert!(crate::live_children::read_record(&record)
11718            .unwrap()
11719            .is_empty());
11720        let history = runtime.terminal_ring.lock().unwrap().snapshot();
11721        assert_eq!(history.entries.len(), 1);
11722        assert_eq!(
11723            history.entries[0].disposition,
11724            TerminalDisposition::DaemonShutdown
11725        );
11726    }
11727
11728    /// Each restart-producing arm has its own state transition. Keeping their
11729    /// lifetime count assertions adjacent prevents a later new arm from silently
11730    /// spending budget without recording the historical restart.
11731    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11732    async fn every_restart_increment_path_advances_lifetime_count() {
11733        let supervisor = Supervisor::new_for_test(
11734            Arc::new(Registry::default()),
11735            RestartPolicy::new(1, Duration::ZERO),
11736        );
11737        let runtime = supervisor.runtime_config();
11738        let spec = ModuleSpec {
11739            module_id: "lifetime-increment-path".to_string(),
11740            program: PathBuf::from("/unused/lifetime-increment-path"),
11741            args: Vec::new(),
11742            env: Vec::new(),
11743            reserved: false,
11744            reserved_prefixes: Vec::new(),
11745            protocol: ModuleProtocol::Subc,
11746            overlap: Default::default(),
11747        };
11748
11749        let crash_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11750        assert!(matches!(
11751            on_child_exit(
11752                &spec,
11753                runtime.restart_policy,
11754                &supervisor.registry,
11755                &crash_snapshot,
11756                &runtime.terminal_ring,
11757                &runtime.spawn_events,
11758                &runtime.child_roster,
11759                ExitReport {
11760                    kind: ExitKind::Crash,
11761                    code: Some(1),
11762                    signal: None,
11763                    at_ms: 1,
11764                },
11765            )
11766            .await,
11767            NextAction::Restart { schedule: _ }
11768        ));
11769        let (crash_restarts, crash_lifetime) = {
11770            let state = lock_snapshot(&crash_snapshot).unwrap();
11771            (state.crash_restarts.len(), state.lifetime_restarts)
11772        };
11773        assert_eq!(crash_restarts, 1);
11774        assert_eq!(crash_lifetime, 1);
11775
11776        let health_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11777        let mut health_child = None;
11778        assert!(matches!(
11779            health_restart_child(
11780                &spec,
11781                &runtime,
11782                &supervisor.registry,
11783                &supervisor.process_liveness,
11784                &health_snapshot,
11785                &mut health_child,
11786                SupervisorHealthStatus::Failing,
11787                None,
11788                2,
11789            )
11790            .await,
11791            Ok(())
11792        ));
11793        assert!(health_child.is_none());
11794        assert!(lock_snapshot(&health_snapshot).unwrap().respawn_pending);
11795        assert!(runtime.scheduled_respawn.lock().unwrap().take().is_some());
11796        let (health_restarts, health_lifetime) = {
11797            let state = lock_snapshot(&health_snapshot).unwrap();
11798            (state.crash_restarts.len(), state.lifetime_restarts)
11799        };
11800        assert_eq!(health_restarts, 1);
11801        assert_eq!(health_lifetime, 1);
11802
11803        let reload_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11804        let mut reload_child = None;
11805        assert!(matches!(
11806            handle_reload_spawn_failure(
11807                &spec,
11808                &runtime,
11809                &supervisor.process_liveness,
11810                &reload_snapshot,
11811                &mut reload_child,
11812                "forced reload spawn failure".to_string(),
11813            )
11814            .await,
11815            Err(SuperviseError::ReloadFailed { .. })
11816        ));
11817        let (reload_restarts, reload_lifetime) = {
11818            let state = lock_snapshot(&reload_snapshot).unwrap();
11819            (state.crash_restarts.len(), state.lifetime_restarts)
11820        };
11821        assert_eq!(reload_restarts, 1);
11822        assert_eq!(reload_lifetime, 1);
11823    }
11824
11825    #[tokio::test]
11826    async fn deliberately_severed_live_child_records_lifetime_without_spending_restart_budget() {
11827        let supervisor = Supervisor::new_for_test(
11828            Arc::new(Registry::default()),
11829            RestartPolicy::new(3, Duration::ZERO),
11830        );
11831        let runtime = supervisor.runtime_config();
11832        let spec = ModuleSpec {
11833            module_id: "deliberately-severed".to_string(),
11834            program: PathBuf::from("/unused/deliberately-severed"),
11835            args: Vec::new(),
11836            env: Vec::new(),
11837            reserved: false,
11838            reserved_prefixes: Vec::new(),
11839            protocol: ModuleProtocol::Subc,
11840            overlap: Default::default(),
11841        };
11842        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11843        let process = ProcessIdentity {
11844            pid: 41,
11845            start_time: 101,
11846        };
11847        record_deliberate_severance(&snapshot, process).unwrap();
11848        let exit_report = apply_deliberate_severance_marker(
11849            &snapshot,
11850            Some(process),
11851            ExitReport {
11852                kind: ExitKind::Crash,
11853                code: Some(1),
11854                signal: None,
11855                at_ms: 1,
11856            },
11857        );
11858        assert_eq!(exit_report.kind, ExitKind::DeliberateSeverance);
11859
11860        assert!(matches!(
11861            on_child_exit(
11862                &spec,
11863                runtime.restart_policy,
11864                &supervisor.registry,
11865                &snapshot,
11866                &runtime.terminal_ring,
11867                &runtime.spawn_events,
11868                &runtime.child_roster,
11869                exit_report,
11870            )
11871            .await,
11872            NextAction::Restart { schedule: _ }
11873        ));
11874        let state = lock_snapshot(&snapshot).unwrap();
11875        assert_eq!(state.lifetime_restarts, 1);
11876        assert_eq!(state.crash_restarts.len(), 0);
11877    }
11878
11879    #[tokio::test]
11880    async fn genuine_crash_spends_restart_budget_and_records_lifetime() {
11881        let supervisor = Supervisor::new_for_test(
11882            Arc::new(Registry::default()),
11883            RestartPolicy::new(3, Duration::ZERO),
11884        );
11885        let runtime = supervisor.runtime_config();
11886        let spec = ModuleSpec {
11887            module_id: "genuine-crash".to_string(),
11888            program: PathBuf::from("/unused/genuine-crash"),
11889            args: Vec::new(),
11890            env: Vec::new(),
11891            reserved: false,
11892            reserved_prefixes: Vec::new(),
11893            protocol: ModuleProtocol::Subc,
11894            overlap: Default::default(),
11895        };
11896        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11897
11898        assert!(matches!(
11899            on_child_exit(
11900                &spec,
11901                runtime.restart_policy,
11902                &supervisor.registry,
11903                &snapshot,
11904                &runtime.terminal_ring,
11905                &runtime.spawn_events,
11906                &runtime.child_roster,
11907                ExitReport {
11908                    kind: ExitKind::Crash,
11909                    code: Some(1),
11910                    signal: None,
11911                    at_ms: 1,
11912                },
11913            )
11914            .await,
11915            NextAction::Restart { schedule: _ }
11916        ));
11917        let state = lock_snapshot(&snapshot).unwrap();
11918        assert_eq!(state.lifetime_restarts, 1);
11919        assert_eq!(state.crash_restarts.len(), 1);
11920    }
11921
11922    fn crash_exit_report(at_ms: u64) -> ExitReport {
11923        ExitReport {
11924            kind: ExitKind::Crash,
11925            code: Some(1),
11926            signal: None,
11927            at_ms,
11928        }
11929    }
11930
11931    fn windowed_crash_spec(module_id: &str) -> ModuleSpec {
11932        ModuleSpec {
11933            module_id: module_id.to_string(),
11934            program: PathBuf::from("/unused").join(module_id),
11935            args: Vec::new(),
11936            env: Vec::new(),
11937            reserved: false,
11938            reserved_prefixes: Vec::new(),
11939            protocol: ModuleProtocol::Subc,
11940            overlap: Default::default(),
11941        }
11942    }
11943
11944    /// A real crash loop still stops. Three crashes with nothing aging out spend
11945    /// a budget of two and the third respawn is refused, and both surfaces an
11946    /// operator has -- the log line and the retained terminal record -- name the
11947    /// window rather than only the cap, because `max_restarts=2` alone is what
11948    /// this budget used to mean.
11949    #[tokio::test]
11950    async fn three_crashes_inside_the_window_stop_the_module_and_name_the_window() {
11951        let (logs, _guard) = crate::router::test_log::log_capture(tracing::Level::ERROR);
11952        let supervisor = Supervisor::new_for_test(
11953            Arc::new(Registry::default()),
11954            RestartPolicy::new(2, Duration::ZERO),
11955        );
11956        let runtime = supervisor.runtime_config();
11957        let spec = windowed_crash_spec("crash-loop-in-window");
11958        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11959
11960        for attempt in 1..=2 {
11961            assert!(
11962                matches!(
11963                    on_child_exit(
11964                        &spec,
11965                        runtime.restart_policy,
11966                        &supervisor.registry,
11967                        &snapshot,
11968                        &runtime.terminal_ring,
11969                        &runtime.spawn_events,
11970                        &runtime.child_roster,
11971                        crash_exit_report(attempt),
11972                    )
11973                    .await,
11974                    NextAction::Restart { schedule: _ }
11975                ),
11976                "crash {attempt} is inside the budget and must respawn"
11977            );
11978        }
11979
11980        assert!(matches!(
11981            on_child_exit(
11982                &spec,
11983                runtime.restart_policy,
11984                &supervisor.registry,
11985                &snapshot,
11986                &runtime.terminal_ring,
11987                &runtime.spawn_events,
11988                &runtime.child_roster,
11989                crash_exit_report(3),
11990            )
11991            .await,
11992            NextAction::Stop { .. }
11993        ));
11994
11995        {
11996            let state = lock_snapshot(&snapshot).unwrap();
11997            assert_eq!(state.state, ModuleState::Failed);
11998            assert_eq!(state.crash_restarts.len(), 2);
11999            assert_eq!(state.lifetime_restarts, 2);
12000        }
12001
12002        let history = runtime
12003            .terminal_ring
12004            .lock()
12005            .expect("terminal ring is not poisoned")
12006            .snapshot();
12007        let last = history
12008            .entries
12009            .last()
12010            .expect("the refused crash is retained");
12011        assert_eq!(last.disposition, TerminalDisposition::Failed);
12012        assert_eq!(
12013            last.disposition_detail.as_deref(),
12014            Some("crash budget exhausted: max_restarts=2 within window_secs=600")
12015        );
12016
12017        let captured = crate::router::test_log::captured_logs(&logs);
12018        assert!(
12019            captured.contains("crash budget exhausted: max_restarts=2 within window_secs=600"),
12020            "the stop must be logged with its window: {captured}"
12021        );
12022    }
12023
12024    /// The rate, stated as a test: three crashes where the first has aged past
12025    /// the window are two crashes as far as the budget is concerned, so the
12026    /// third respawn is allowed and the ring holds only the two recent ones.
12027    ///
12028    /// This is the case a lifetime counter got wrong -- and the case the daemon
12029    /// now hits routinely, since a module exits non-zero every time its
12030    /// connection to the daemon drops.
12031    #[tokio::test]
12032    async fn a_crash_older_than_the_window_frees_its_slot_for_a_later_crash() {
12033        let supervisor = Supervisor::new_for_test(
12034            Arc::new(Registry::default()),
12035            RestartPolicy::new(2, Duration::ZERO),
12036        );
12037        let runtime = supervisor.runtime_config();
12038        let spec = windowed_crash_spec("crash-across-windows");
12039        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
12040
12041        for attempt in 1..=2 {
12042            assert!(matches!(
12043                on_child_exit(
12044                    &spec,
12045                    runtime.restart_policy,
12046                    &supervisor.registry,
12047                    &snapshot,
12048                    &runtime.terminal_ring,
12049                    &runtime.spawn_events,
12050                    &runtime.child_roster,
12051                    crash_exit_report(attempt),
12052                )
12053                .await,
12054                NextAction::Restart { schedule: _ }
12055            ));
12056        }
12057
12058        // The oldest crash moves out of the window; nothing else about the
12059        // module changes.
12060        update_snapshot(&snapshot, Some(&spec.module_id), |state| {
12061            age_oldest_crash_restart_out_of_window(state, runtime.restart_policy.window);
12062        })
12063        .unwrap();
12064
12065        assert!(
12066            matches!(
12067                on_child_exit(
12068                    &spec,
12069                    runtime.restart_policy,
12070                    &supervisor.registry,
12071                    &snapshot,
12072                    &runtime.terminal_ring,
12073                    &runtime.spawn_events,
12074                    &runtime.child_roster,
12075                    crash_exit_report(3),
12076                )
12077                .await,
12078                NextAction::Restart { schedule: _ }
12079            ),
12080            "a crash older than the window must not hold a budget slot"
12081        );
12082
12083        let state = lock_snapshot(&snapshot).unwrap();
12084        assert_eq!(state.state, ModuleState::Restarting);
12085        assert_eq!(
12086            state.crash_restarts.len(),
12087            2,
12088            "the aged instant is dropped and the new one takes its place"
12089        );
12090        assert_eq!(
12091            state.lifetime_restarts, 3,
12092            "the ledger counts every restart, including the ones the window forgot"
12093        );
12094    }
12095
12096    /// An operator restart hands the budget back whole, and the ledger keeps
12097    /// counting. Those are different questions -- "how close is this module to
12098    /// being stopped" and "how many times has it been replaced" -- and the
12099    /// operator action answers only the first.
12100    #[tokio::test]
12101    async fn an_operator_restart_clears_the_ring_and_leaves_the_ledger_alone() {
12102        let supervisor = Supervisor::new_for_test(
12103            Arc::new(Registry::default()),
12104            RestartPolicy::new(2, Duration::ZERO),
12105        );
12106        let runtime = supervisor.runtime_config();
12107        let spec = windowed_crash_spec("operator-cleared-budget");
12108        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
12109
12110        for attempt in 1..=2 {
12111            assert!(matches!(
12112                on_child_exit(
12113                    &spec,
12114                    runtime.restart_policy,
12115                    &supervisor.registry,
12116                    &snapshot,
12117                    &runtime.terminal_ring,
12118                    &runtime.spawn_events,
12119                    &runtime.child_roster,
12120                    crash_exit_report(attempt),
12121                )
12122                .await,
12123                NextAction::Restart { schedule: _ }
12124            ));
12125        }
12126
12127        reset_restart_count(&snapshot, &spec.module_id).unwrap();
12128        {
12129            let state = lock_snapshot(&snapshot).unwrap();
12130            assert!(
12131                state.crash_restarts.is_empty(),
12132                "an operator restart returns the full budget"
12133            );
12134            assert_eq!(
12135                state.lifetime_restarts, 2,
12136                "clearing the budget must not unmake the crashes"
12137            );
12138        }
12139
12140        assert!(
12141            matches!(
12142                on_child_exit(
12143                    &spec,
12144                    runtime.restart_policy,
12145                    &supervisor.registry,
12146                    &snapshot,
12147                    &runtime.terminal_ring,
12148                    &runtime.spawn_events,
12149                    &runtime.child_roster,
12150                    crash_exit_report(3),
12151                )
12152                .await,
12153                NextAction::Restart { schedule: _ }
12154            ),
12155            "the cleared budget must be spendable again"
12156        );
12157        let state = lock_snapshot(&snapshot).unwrap();
12158        assert_eq!(state.crash_restarts.len(), 1);
12159        assert_eq!(state.lifetime_restarts, 3);
12160    }
12161
12162    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
12163    async fn severance_marker_for_a_dead_child_does_not_label_its_successor() {
12164        let severed = ProcessIdentity {
12165            pid: 41,
12166            start_time: 101,
12167        };
12168        let successor = ProcessIdentity {
12169            pid: 41,
12170            start_time: 202,
12171        };
12172        let module = module_with_recovery_snapshot(ModuleState::Running, true, 0);
12173        update_snapshot(&module.inner.snapshot, Some("recovery-snapshot"), |state| {
12174            state.pid = Some(successor.pid);
12175            state.process_start_time = Some(successor.start_time);
12176        })
12177        .unwrap();
12178        assert!(!module.record_deliberate_severance(severed).unwrap());
12179
12180        let exit_report = apply_deliberate_severance_marker(
12181            &module.inner.snapshot,
12182            Some(successor),
12183            ExitReport {
12184                kind: ExitKind::Crash,
12185                code: Some(1),
12186                signal: None,
12187                at_ms: 1,
12188            },
12189        );
12190
12191        assert_eq!(exit_report.kind, ExitKind::Crash);
12192    }
12193
12194    #[tokio::test]
12195    async fn drain_reap_marks_deliberate_severance_and_records_lifetime_without_budget() {
12196        let registry = Registry::default();
12197        let supervisor = Supervisor::new_for_test(
12198            Arc::new(Registry::default()),
12199            RestartPolicy::new(3, Duration::ZERO),
12200        );
12201        let runtime = supervisor.runtime_config();
12202        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
12203        let spec = ModuleSpec {
12204            module_id: "drain-deliberate-severance".to_string(),
12205            program: fake_aft_stub_path(),
12206            args: Vec::new(),
12207            env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
12208            reserved: false,
12209            reserved_prefixes: Vec::new(),
12210            protocol: ModuleProtocol::Subc,
12211            overlap: Default::default(),
12212        };
12213        let mut child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
12214        let process = ProcessIdentity {
12215            pid: 41,
12216            start_time: 101,
12217        };
12218        child.process_identity = Some(process);
12219        update_snapshot(&snapshot, Some(&spec.module_id), |state| {
12220            state.pid = Some(process.pid);
12221            state.process_start_time = Some(process.start_time);
12222        })
12223        .unwrap();
12224        record_deliberate_severance(&snapshot, process).unwrap();
12225
12226        drain_child_to_state(
12227            &spec.module_id,
12228            spec.protocol,
12229            // The child exits on its own; no signal may change the exit this
12230            // test classifies.
12231            StopNotice::SentOverConnection,
12232            &registry,
12233            None,
12234            &snapshot,
12235            &runtime.terminal_ring,
12236            &runtime.spawn_events,
12237            child,
12238            Duration::from_secs(1),
12239            ModuleState::Stopped,
12240            Some(false),
12241        )
12242        .await
12243        .unwrap();
12244
12245        let state = lock_snapshot(&snapshot).unwrap();
12246        assert_eq!(
12247            state.last_exit.as_ref().map(|exit| exit.kind),
12248            Some(ExitKind::DeliberateSeverance)
12249        );
12250        assert_eq!(state.lifetime_restarts, 1);
12251        assert_eq!(state.crash_restarts.len(), 0);
12252        drop(state);
12253        let history = runtime.terminal_ring.lock().unwrap().snapshot();
12254        assert_eq!(
12255            history.entries[0].exit_kind,
12256            subc_control::TerminalExitKind::DeliberateSeverance
12257        );
12258    }
12259
12260    #[tokio::test]
12261    async fn ordinary_drain_reap_does_not_record_a_lifetime_restart() {
12262        let registry = Registry::default();
12263        let supervisor = Supervisor::new_for_test(
12264            Arc::new(Registry::default()),
12265            RestartPolicy::new(3, Duration::ZERO),
12266        );
12267        let runtime = supervisor.runtime_config();
12268        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
12269        let spec = ModuleSpec {
12270            module_id: "ordinary-drain".to_string(),
12271            program: fake_aft_stub_path(),
12272            args: Vec::new(),
12273            env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
12274            reserved: false,
12275            reserved_prefixes: Vec::new(),
12276            protocol: ModuleProtocol::Subc,
12277            overlap: Default::default(),
12278        };
12279        let child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
12280
12281        drain_child_to_state(
12282            &spec.module_id,
12283            spec.protocol,
12284            // The child exits on its own; no signal may change the exit this
12285            // test classifies.
12286            StopNotice::SentOverConnection,
12287            &registry,
12288            None,
12289            &snapshot,
12290            &runtime.terminal_ring,
12291            &runtime.spawn_events,
12292            child,
12293            Duration::from_secs(1),
12294            ModuleState::Stopped,
12295            Some(false),
12296        )
12297        .await
12298        .unwrap();
12299
12300        let state = lock_snapshot(&snapshot).unwrap();
12301        assert_eq!(
12302            state.last_exit.as_ref().map(|exit| exit.kind),
12303            Some(ExitKind::Crash)
12304        );
12305        assert_eq!(state.lifetime_restarts, 0);
12306        assert_eq!(state.crash_restarts.len(), 0);
12307    }
12308
12309    #[test]
12310    fn fatal_connection_teardown_cannot_arm_a_marker_for_a_surviving_process() {
12311        // The server's generic fatal-routing branch only knows that the
12312        // connection failed; it does not know that the daemon deliberately
12313        // initiated a process-killing severance. Keep this seam explicit so a
12314        // future connection error path cannot silently reintroduce the stale
12315        // exemption that mislabels a later genuine crash.
12316        assert!(!include_str!("server.rs")
12317            .contains("router.record_deliberate_connection_severance(ctx.connection_id)"));
12318    }
12319
12320    /// The `route.closed` `drained` value must be the quiescence wait's own
12321    /// measurement (`Ok`), never invented -- except on `Err`, where there is no
12322    /// measurement at all and `false` is the one honest constant. This is the exact
12323    /// logic `begin_forwarding_drain_with` now applies before sending `route.closed`
12324    /// on every return path, including the one that used to return early via `?`
12325    /// with `route.closing` already sent and no `route.closed` ever following.
12326    #[test]
12327    fn drained_after_quiescence_wait_passes_ok_through_and_forces_false_on_err() {
12328        assert!(drained_after_quiescence_wait(&Ok(true)));
12329        assert!(!drained_after_quiescence_wait(&Ok(false)));
12330        assert!(!drained_after_quiescence_wait(&Err(
12331            SuperviseError::StatePoisoned { module_id: None }
12332        )));
12333    }
12334
12335    /// `supervise_loop`'s `wait()`-error arm now calls `record_terminal` like every
12336    /// other exit path does, so a module whose child `wait()` itself errored (e.g.
12337    /// already reaped out-of-band) still leaves a terminal record rather than none
12338    /// at all. Triggering the real `wait()` I/O error from an integration test would
12339    /// need a genuine already-reaped-child race, which is OS-specific and not
12340    /// something this suite attempts elsewhere; this test instead verifies the
12341    /// record produced for that arm end-to-end through the real `TerminalRing`, and
12342    /// the call site itself is verified by inspection to sit in that exact arm.
12343    #[test]
12344    fn wait_error_exit_report_records_a_failed_terminal_with_no_code_or_signal() {
12345        let ring = Arc::new(Mutex::new(TerminalRing::new(
12346            TerminalRingConfig::default(),
12347            0,
12348        )));
12349        record_wait_error_terminal("wait-error", &ring, &super::SpawnEventFeed::default());
12350
12351        let snapshot = ring.lock().unwrap().snapshot();
12352        assert_eq!(snapshot.entries.len(), 1);
12353        let entry = &snapshot.entries[0];
12354        assert_eq!(entry.exit_code, None);
12355        assert_eq!(entry.exit_signal, None);
12356        assert_eq!(entry.disposition, TerminalDisposition::Failed);
12357    }
12358
12359    #[test]
12360    fn wait_error_exit_path_preserves_spawn_event_density() {
12361        let feed = super::SpawnEventFeed::default();
12362        feed.configure_incarnation("wait-error-density".to_string());
12363        feed.emit_spawned("wait-error", 41, 1);
12364        let ring = Arc::new(Mutex::new(TerminalRing::new(
12365            TerminalRingConfig::default(),
12366            0,
12367        )));
12368
12369        record_wait_error_terminal("wait-error", &ring, &feed);
12370        feed.emit_spawned("after-wait-error", 42, 2);
12371
12372        let state = feed.0.lock().unwrap();
12373        let sequences = state
12374            .events
12375            .iter()
12376            .map(|event| event.cursor.seq)
12377            .collect::<Vec<_>>();
12378        assert_eq!(sequences, vec![1, 2, 3]);
12379        assert_eq!(state.events[1].kind, SpawnEventKind::Exited);
12380        assert_eq!(state.events[1].exit_code, None);
12381        assert_eq!(state.events[1].exit_signal, None);
12382    }
12383
12384    /// Pins the report's `kind` too: the wait-error arm treats an unwaitable child
12385    /// as a crash (matching `fail_snapshot`'s `Failed` disposition for this arm),
12386    /// not a clean exit it never actually observed.
12387    #[test]
12388    fn wait_error_exit_report_is_classified_as_a_crash() {
12389        assert_eq!(wait_error_exit_report().kind, ExitKind::Crash);
12390    }
12391}
12392
12393#[cfg(all(test, unix))]
12394mod health_event_tests {
12395    use super::*;
12396    use cortexkit_test_support::ScratchDir;
12397    use subc_protocol::{manifest::Concurrency, session::ModuleControlResponse};
12398
12399    struct Actor {
12400        module: SupervisedModule,
12401        registry: Arc<Registry>,
12402        forwarding: Arc<ForwardingTable>,
12403        spec: ModuleSpec,
12404        health: HealthConfig,
12405        rx: mpsc::Receiver<crate::router::OutboundFrame>,
12406        _home: ScratchDir,
12407    }
12408
12409    async fn wait_for<T>(reason: &str, mut observe: impl FnMut() -> Option<T>) -> T {
12410        // OS exec and socket readiness are real I/O. Stay runnable while waiting
12411        // for an observed condition, rather than letting paused time auto-advance
12412        // deadlines or assuming a fixed number of yields finishes the work.
12413        let deadline = std::time::Instant::now() + Duration::from_secs(10);
12414        let tick = Instant::now();
12415        loop {
12416            if let Some(value) = observe() {
12417                assert_eq!(Instant::now(), tick, "{reason} must not wait for a timer");
12418                return value;
12419            }
12420            assert!(
12421                std::time::Instant::now() < deadline,
12422                "timed out waiting for {reason}"
12423            );
12424            tokio::task::yield_now().await;
12425        }
12426    }
12427
12428    impl Actor {
12429        async fn start(protocol: ModuleProtocol, cadence: Duration) -> Self {
12430            let home = ScratchDir::new("health-events");
12431            let registry = Arc::new(Registry::default());
12432            let forwarding = Arc::new(ForwardingTable::default());
12433            let health = HealthConfig {
12434                cadence,
12435                deadline: Duration::from_secs(3600),
12436                ..HealthConfig::default()
12437            };
12438            let supervisor =
12439                Supervisor::new_for_test(registry.clone(), RestartPolicy::new(3, Duration::ZERO))
12440                    .with_forwarding(forwarding.clone())
12441                    .with_health_config(health.clone());
12442            let spec = ModuleSpec {
12443                module_id: "event-health-child".into(),
12444                program: PathBuf::from("/bin/sh"),
12445                // The process stays alive but has no real bus peer. Registrations
12446                // below exercise the same registry writes as accepted HELLOs.
12447                args: vec!["-c".into(), "exec sleep 600".into()],
12448                env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
12449                    .into_iter()
12450                    .map(|key| (key.into(), home.path().display().to_string()))
12451                    .collect(),
12452                reserved: false,
12453                reserved_prefixes: vec![],
12454                protocol,
12455                overlap: Default::default(),
12456            };
12457            let module = supervisor.spawn(spec.clone()).unwrap();
12458            let (_, rx) = mpsc::channel(8);
12459            let actor = Self {
12460                module,
12461                registry,
12462                forwarding,
12463                spec,
12464                health,
12465                rx,
12466                _home: home,
12467            };
12468            actor
12469                .wait_for_select(|checkpoint| checkpoint.generation > 0)
12470                .await;
12471            actor
12472        }
12473
12474        fn turns(&self) -> u64 {
12475            lock_snapshot(&self.module.inner.snapshot)
12476                .unwrap()
12477                .actor_turns
12478        }
12479
12480        async fn wait_for_select(
12481            &self,
12482            expected: impl Fn(&ActorSelectCheckpoint) -> bool,
12483        ) -> ActorSelectCheckpoint {
12484            wait_for("actor select checkpoint", || {
12485                let state = lock_snapshot(&self.module.inner.snapshot).unwrap();
12486                state.actor_select.filter(|checkpoint| {
12487                    checkpoint.generation == state.spawn_generation
12488                        && state.state == ModuleState::Running
12489                        && state.process_alive
12490                        && state.reported_pid().is_some()
12491                        && expected(checkpoint)
12492                })
12493            })
12494            .await
12495        }
12496
12497        fn hello(&mut self, connection: u64, health: bool) {
12498            let manifest =
12499                subc_protocol::manifest::ModuleManifest::builder(&self.spec.module_id, "1.0.0")
12500                    .protocol_ver(subc_protocol::PROTOCOL_VERSION)
12501                    .build();
12502            let connection = ConnectionId::new(connection);
12503            let (tx, rx) = mpsc::channel(8);
12504            self.rx = rx;
12505            self.forwarding
12506                .register_module_connection(
12507                    connection,
12508                    self.spec.module_id.clone(),
12509                    subc_protocol::PROTOCOL_VERSION,
12510                    Concurrency::ModuleManaged,
12511                    FrameSink::new(tx),
12512                )
12513                .unwrap();
12514            self.registry
12515                .register_with_control_ops(
12516                    manifest,
12517                    subc_protocol::PROTOCOL_VERSION,
12518                    connection,
12519                    if health {
12520                        vec![MODULE_CONTROL_OP_HEALTH_CHECK.into()]
12521                    } else {
12522                        vec![]
12523                    },
12524                )
12525                .unwrap();
12526        }
12527
12528        async fn probe(&mut self) -> crate::router::OutboundFrame {
12529            let frame = wait_for("outbound health probe", || match self.rx.try_recv() {
12530                Ok(frame) => Some(frame),
12531                Err(mpsc::error::TryRecvError::Empty) => None,
12532                Err(mpsc::error::TryRecvError::Disconnected) => panic!("health peer disconnected"),
12533            })
12534            .await;
12535            let body: Value = serde_json::from_slice(&frame.body).unwrap();
12536            assert_eq!(body["op"], MODULE_CONTROL_OP_HEALTH_CHECK);
12537            frame
12538        }
12539
12540        fn no_probe(&mut self) {
12541            assert!(matches!(
12542                self.rx.try_recv(),
12543                Err(mpsc::error::TryRecvError::Empty)
12544            ));
12545        }
12546
12547        fn answer(&self, connection: u64, frame: crate::router::OutboundFrame) {
12548            self.forwarding
12549                .complete_module_control_rpc(
12550                    ConnectionId::new(connection),
12551                    frame.header.corr,
12552                    Some(MODULE_CONTROL_OP_HEALTH_CHECK),
12553                    ModuleControlRpcOutcome::Response(ModuleControlResponse::HealthCheck {
12554                        status: HealthStatus::Ok,
12555                        detail: None,
12556                        metrics: None,
12557                    }),
12558                )
12559                .unwrap();
12560        }
12561    }
12562
12563    #[tokio::test(start_paused = true)]
12564    async fn no_health_children_park_for_sixty_seconds() {
12565        let none = Actor::start(ModuleProtocol::None, Duration::from_secs(30)).await;
12566        let unregistered = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12567        let mut unadvertised = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12568        unadvertised.hello(1, false);
12569        // Include any one-off registration wake in the budget; receiving a
12570        // no-health HELLO must not turn a parked child into a polling child.
12571        // Advance in 10 ms steps so the old poll really executes ~6,000 turns;
12572        // a single 60 s jump would only observe one expired timer.
12573        for _ in 0..6000 {
12574            tokio::time::advance(Duration::from_millis(10)).await;
12575            tokio::task::yield_now().await;
12576        }
12577        for actor in [&none, &unregistered, &unadvertised] {
12578            assert!(
12579                actor.turns() <= 5,
12580                "no-health actor woke {} times",
12581                actor.turns()
12582            );
12583            assert_eq!(actor.module.state().unwrap(), ModuleState::Running);
12584        }
12585    }
12586
12587    #[tokio::test(start_paused = true)]
12588    async fn late_hello_starts_a_due_probe_in_the_notification_tick() {
12589        // With a zero cadence the first probe is due at once, so this measures
12590        // only how fast the registration notification starts health probing,
12591        // not the normal 30-33 s wait before a first probe (checked below).
12592        let mut actor = Actor::start(ModuleProtocol::Subc, Duration::ZERO).await;
12593        let tick = Instant::now();
12594        actor.hello(1, true);
12595        actor.probe().await;
12596        assert_eq!(Instant::now(), tick, "HELLO must not wait for a poll");
12597    }
12598
12599    #[tokio::test(start_paused = true)]
12600    async fn hello_keeps_the_jittered_cadence_and_catalog_updates_do_not_reset_it() {
12601        let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12602        let hello_tick = Instant::now();
12603        actor.hello(1, true);
12604        let armed = actor
12605            .wait_for_select(|checkpoint| {
12606                checkpoint.registered_connection == Some(ConnectionId::new(1))
12607                    && checkpoint.next_probe_at.is_some()
12608            })
12609            .await;
12610        let delay = jittered_health_delay(&actor.spec.module_id, 0, actor.health.cadence);
12611        assert!((Duration::from_secs(30)..Duration::from_secs(33)).contains(&delay));
12612        let first_deadline = hello_tick + delay;
12613        assert_eq!(armed.next_probe_at, Some(first_deadline));
12614        tokio::time::advance(Duration::from_secs(29)).await;
12615        let turns = actor.turns();
12616        actor
12617            .registry
12618            .replace_catalog_for_connection(ConnectionId::new(1), vec![], None, Some(true))
12619            .unwrap();
12620        let updated = actor
12621            .wait_for_select(|checkpoint| checkpoint.turn > turns)
12622            .await;
12623        assert_eq!(
12624            updated.next_probe_at,
12625            Some(first_deadline),
12626            "catalog update must not reset cadence"
12627        );
12628        actor.no_probe();
12629        tokio::time::advance(first_deadline - Instant::now()).await;
12630        let frame = actor.probe().await;
12631        actor.answer(1, frame);
12632        let next = jittered_health_delay(&actor.spec.module_id, 1, actor.health.cadence);
12633        let next_deadline = Instant::now() + next;
12634        let rearmed = actor
12635            .wait_for_select(|checkpoint| {
12636                checkpoint
12637                    .next_probe_at
12638                    .is_some_and(|deadline| deadline > first_deadline)
12639            })
12640            .await;
12641        assert_eq!(rearmed.next_probe_at, Some(next_deadline));
12642        tokio::time::advance(next - Duration::from_nanos(1)).await;
12643        actor.no_probe();
12644        tokio::time::advance(Duration::from_nanos(1)).await;
12645        actor.probe().await;
12646    }
12647
12648    #[tokio::test(start_paused = true)]
12649    async fn disconnect_disarms_and_reconnect_rearms_health() {
12650        let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12651        actor.hello(1, true);
12652        actor
12653            .wait_for_select(|checkpoint| {
12654                checkpoint.registered_connection == Some(ConnectionId::new(1))
12655                    && checkpoint.next_probe_at.is_some()
12656            })
12657            .await;
12658        let turns = actor.turns();
12659        let tick = Instant::now();
12660        actor
12661            .registry
12662            .deregister_connection(ConnectionId::new(1))
12663            .unwrap();
12664        let disarmed = actor
12665            .wait_for_select(|checkpoint| {
12666                checkpoint.turn > turns && checkpoint.registered_connection.is_none()
12667            })
12668            .await;
12669        assert_eq!(disarmed.next_probe_at, None);
12670        assert_eq!(disarmed.wake_after, None);
12671        assert_eq!(
12672            Instant::now(),
12673            tick,
12674            "disconnect must disarm in the notification tick"
12675        );
12676        tokio::time::advance(Duration::from_secs(60)).await;
12677        actor.no_probe();
12678        let reconnect_tick = Instant::now();
12679        actor.hello(2, true);
12680        let rearmed = actor
12681            .wait_for_select(|checkpoint| {
12682                checkpoint.registered_connection == Some(ConnectionId::new(2))
12683                    && checkpoint.next_probe_at.is_some()
12684            })
12685            .await;
12686        let delay = jittered_health_delay(&actor.spec.module_id, 0, actor.health.cadence);
12687        assert_eq!(rearmed.next_probe_at, Some(reconnect_tick + delay));
12688        tokio::time::advance(delay).await;
12689        actor.probe().await;
12690    }
12691
12692    #[tokio::test(start_paused = true)]
12693    async fn reregistration_without_health_disarms_the_old_schedule() {
12694        let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12695        actor.hello(1, true);
12696        actor
12697            .wait_for_select(|checkpoint| {
12698                checkpoint.registered_connection == Some(ConnectionId::new(1))
12699                    && checkpoint.next_probe_at.is_some()
12700            })
12701            .await;
12702        actor
12703            .registry
12704            .deregister_connection(ConnectionId::new(1))
12705            .unwrap();
12706        actor.hello(2, false);
12707        let disarmed = actor
12708            .wait_for_select(|checkpoint| {
12709                checkpoint.registered_connection == Some(ConnectionId::new(2))
12710            })
12711            .await;
12712        assert_eq!(disarmed.next_probe_at, None);
12713        assert_eq!(disarmed.wake_after, None);
12714        tokio::time::advance(Duration::from_secs(60)).await;
12715        actor.no_probe();
12716    }
12717
12718    #[tokio::test(start_paused = true)]
12719    async fn restart_waits_for_its_own_hello_before_rearming_health() {
12720        let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12721        actor.hello(1, true);
12722        let armed = actor
12723            .wait_for_select(|checkpoint| {
12724                checkpoint.registered_connection == Some(ConnectionId::new(1))
12725                    && checkpoint.next_probe_at.is_some()
12726            })
12727            .await;
12728        // Simulate the old process's connection closing before the restart
12729        // tears the process down. The replacement process has started but has
12730        // not sent its HELLO, so it is not registered.
12731        actor
12732            .registry
12733            .deregister_connection(ConnectionId::new(1))
12734            .unwrap();
12735        actor.module.restart(Some(0)).await.unwrap();
12736        let parked = actor
12737            .wait_for_select(|checkpoint| {
12738                checkpoint.generation > armed.generation
12739                    && checkpoint.registered_connection.is_none()
12740                    && checkpoint.next_probe_at.is_none()
12741            })
12742            .await;
12743        assert_eq!(parked.wake_after, None);
12744        actor.rx.close();
12745        // Drain the stop notice the restart queued for the old process's connection,
12746        // so it isn't mistaken for traffic to the replacement.
12747        while actor.rx.try_recv().is_ok() {}
12748        let turns = actor.turns();
12749        tokio::time::advance(Duration::from_secs(60)).await;
12750        let after = actor.turns();
12751        eprintln!(
12752            "restart park turns: before={turns} after={after} delta={}",
12753            after - turns
12754        );
12755        assert_eq!(after, turns, "replacement must park before HELLO");
12756        let hello_tick = Instant::now();
12757        actor.hello(2, true);
12758        let rearmed = actor
12759            .wait_for_select(|checkpoint| {
12760                checkpoint.registered_connection == Some(ConnectionId::new(2))
12761                    && checkpoint.next_probe_at.is_some()
12762            })
12763            .await;
12764        let delay = jittered_health_delay(&actor.spec.module_id, 0, actor.health.cadence);
12765        assert_eq!(rearmed.next_probe_at, Some(hello_tick + delay));
12766        tokio::time::advance(delay).await;
12767        actor.probe().await;
12768    }
12769
12770    #[tokio::test(start_paused = true)]
12771    async fn rescan_adds_and_removes_http_health_without_polling() {
12772        use tokio::{
12773            io::{AsyncReadExt, AsyncWriteExt},
12774            net::TcpListener,
12775        };
12776        let mut actor = Actor::start(ModuleProtocol::None, Duration::from_secs(30)).await;
12777        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
12778        actor.health.http = Some(format!("http://{}/health", listener.local_addr().unwrap()));
12779        // One millisecond separates successive probes without a polling delay.
12780        actor.health.cadence = Duration::from_millis(1);
12781        let (started_tx, mut started_rx) = mpsc::channel(8);
12782        let peer = tokio::spawn(async move {
12783            loop {
12784                let (mut socket, _) = listener.accept().await.unwrap();
12785                let mut request = [0; 1024];
12786                assert!(socket.read(&mut request).await.unwrap() > 0);
12787                started_tx.send(()).await.unwrap();
12788                socket
12789                    .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
12790                    .await
12791                    .unwrap();
12792            }
12793        });
12794        let tick = Instant::now();
12795        let turns = actor.turns();
12796        actor
12797            .module
12798            .update_configuration(actor.spec.clone(), actor.health.clone(), None)
12799            .await
12800            .unwrap();
12801        let armed = actor
12802            .wait_for_select(|checkpoint| {
12803                checkpoint.turn > turns && checkpoint.next_probe_at.is_some()
12804            })
12805            .await;
12806        assert_eq!(armed.next_probe_at, Some(tick + Duration::from_millis(1)));
12807        assert_eq!(Instant::now(), tick);
12808        tokio::time::advance(Duration::from_millis(1)).await;
12809        let mut started = false;
12810        wait_for("successful HTTP probe", || {
12811            started |= started_rx.try_recv().is_ok();
12812            (started && actor.module.status().unwrap().health.status == SupervisorHealthStatus::Ok)
12813                .then_some(())
12814        })
12815        .await;
12816        assert!(started, "added HTTP check must start at its first deadline");
12817        assert_eq!(
12818            actor.module.status().unwrap().health.status,
12819            SupervisorHealthStatus::Ok
12820        );
12821        assert_eq!(Instant::now(), tick + Duration::from_millis(1));
12822        actor.health.http = None;
12823        let turns = actor.turns();
12824        actor
12825            .module
12826            .update_configuration(actor.spec.clone(), actor.health.clone(), None)
12827            .await
12828            .unwrap();
12829        let parked = actor
12830            .wait_for_select(|checkpoint| {
12831                checkpoint.turn > turns && checkpoint.next_probe_at.is_none()
12832            })
12833            .await;
12834        assert_eq!(parked.wake_after, None);
12835        let turns = actor.turns();
12836        tokio::time::advance(Duration::from_secs(60)).await;
12837        assert_eq!(
12838            actor.turns(),
12839            turns,
12840            "removed HTTP check must park the actor"
12841        );
12842        assert!(
12843            started_rx.try_recv().is_err(),
12844            "removed HTTP check must stay disarmed"
12845        );
12846        assert_eq!(
12847            actor.module.status().unwrap().health,
12848            ModuleHealthStatus::default()
12849        );
12850        peer.abort();
12851    }
12852}
12853
12854#[cfg(test)]
12855mod health_evidence_tests {
12856    use super::{HealthProbeError, HealthProbeEvidence};
12857    use std::collections::HashSet;
12858
12859    /// The evidential asymmetry, asserted rather than described.
12860    ///
12861    /// Exactly ONE observation is proof a module cannot serve, and the one that
12862    /// fires under CPU starvation is not it. Before the split, all fifteen
12863    /// construction sites collapsed into a single String, so a timeout carried the
12864    /// same weight as a dead lane -- which is how a healthy module was restarted
12865    /// three times in one day.
12866    #[test]
12867    fn only_a_dead_lane_is_proof_of_death() {
12868        assert!(HealthProbeError::lane_dead("gone").is_proof_of_death());
12869        // Three non-proof classes, each for a different reason: silence is
12870        // consistent with health, a bad answer proves the module ALIVE, and a
12871        // daemon-side fault never reached the module at all.
12872        assert!(!HealthProbeError::no_answer("timed out").is_proof_of_death());
12873        assert!(!HealthProbeError::bad_answer("garbage").is_proof_of_death());
12874        assert!(!HealthProbeError::misconfigured("no table").is_proof_of_death());
12875    }
12876
12877    /// Labels must be distinct, or the operator-facing distinction is cosmetic.
12878    ///
12879    /// A shared label renders two different observations identically in the line an
12880    /// operator reads after an unexplained restart -- the exact confusion this
12881    /// change removes.
12882    #[test]
12883    fn every_evidence_class_has_a_distinct_label() {
12884        let labels = [
12885            HealthProbeError::lane_dead("").label(),
12886            HealthProbeError::no_answer("").label(),
12887            HealthProbeError::bad_answer("").label(),
12888            HealthProbeError::misconfigured("").label(),
12889        ];
12890        let unique: HashSet<_> = labels.iter().collect();
12891        assert_eq!(unique.len(), labels.len(), "labels collided: {labels:?}");
12892    }
12893
12894    /// The class is additional information, not a replacement.
12895    ///
12896    /// An operator needs both "this was silence" and the specific text saying how
12897    /// long we waited; a classification that swallowed the message would trade one
12898    /// missing distinction for another.
12899    #[test]
12900    fn classification_preserves_the_original_message() {
12901        let err = HealthProbeError::no_answer("module did not answer within 5s");
12902        assert_eq!(err.to_string(), "module did not answer within 5s");
12903        assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
12904    }
12905}
12906
12907#[cfg(test)]
12908mod health_tombstone_tests {
12909    use std::{path::PathBuf, sync::Arc, time::Duration};
12910
12911    use subc_protocol::{
12912        manifest::Concurrency,
12913        session::{HealthStatus, ModuleControlResponse},
12914    };
12915    use tokio::sync::mpsc;
12916
12917    use super::{
12918        probe_module_health, HealthAction, HealthConfig, HealthProbeEvidence, ModuleProtocol,
12919        ModuleSpec, RestartPolicy, Supervisor, SupervisorRuntimeConfig,
12920    };
12921    use crate::{
12922        control::ControlHandler,
12923        forwarding::{ForwardingTable, ModuleControlRpcCompletion, ModuleControlRpcOutcome},
12924        registry::{ConnectionId, Registry},
12925        router::FrameSink,
12926    };
12927
12928    struct ProbeHarness {
12929        spec: ModuleSpec,
12930        runtime: SupervisorRuntimeConfig,
12931        forwarding: Arc<ForwardingTable>,
12932        module_connection: ConnectionId,
12933        module_rx: mpsc::Receiver<crate::router::OutboundFrame>,
12934        handler: ControlHandler,
12935        module: super::SupervisedModule,
12936    }
12937
12938    fn probe_harness() -> ProbeHarness {
12939        let registry = Arc::new(Registry::default());
12940        let forwarding = Arc::new(ForwardingTable::default());
12941        let supervisor_handle = super::SupervisorHandle::new();
12942        let health = HealthConfig {
12943            http: None,
12944            cadence: Duration::from_secs(30),
12945            deadline: Duration::from_secs(5),
12946            failure_threshold: 3,
12947            on_degraded: HealthAction::Report,
12948            on_failing: HealthAction::Report,
12949            critical: false,
12950        };
12951        let supervisor = Supervisor::new_for_test(Arc::clone(&registry), RestartPolicy::default())
12952            .with_forwarding(Arc::clone(&forwarding))
12953            .with_handle(supervisor_handle.clone())
12954            .with_health_config(health);
12955        let spec = ModuleSpec {
12956            module_id: "late-health-module".to_string(),
12957            program: PathBuf::from("disabled-module"),
12958            args: Vec::new(),
12959            env: Vec::new(),
12960            reserved: false,
12961            reserved_prefixes: Vec::new(),
12962            protocol: ModuleProtocol::Subc,
12963            overlap: Default::default(),
12964        };
12965        let module = supervisor
12966            .supervise_configured(spec.clone(), false)
12967            .unwrap();
12968        let runtime = supervisor.runtime_config();
12969        let handler = ControlHandler::with_forwarding(registry, Arc::clone(&forwarding))
12970            .with_supervisor(supervisor_handle);
12971        let module_connection = ConnectionId::new(700);
12972        let (module_tx, module_rx) = mpsc::channel(8);
12973        forwarding
12974            .register_module_connection(
12975                module_connection,
12976                spec.module_id.clone(),
12977                subc_protocol::PROTOCOL_VERSION,
12978                Concurrency::ModuleManaged,
12979                FrameSink::new(module_tx),
12980            )
12981            .unwrap();
12982
12983        ProbeHarness {
12984            spec,
12985            runtime,
12986            forwarding,
12987            module_connection,
12988            module_rx,
12989            handler,
12990            module,
12991        }
12992    }
12993
12994    async fn finish_after(
12995        harness: &mut ProbeHarness,
12996        stall: Duration,
12997    ) -> ModuleControlRpcCompletion {
12998        assert!(stall > harness.runtime.health.deadline);
12999        let deadline = harness.runtime.health.deadline;
13000        let probe = probe_module_health(&harness.spec.module_id, &harness.runtime, None);
13001        let answer = async {
13002            let frame = harness.module_rx.recv().await.expect("health.check frame");
13003            tokio::time::advance(deadline).await;
13004            tokio::task::yield_now().await;
13005            tokio::time::advance(stall - deadline).await;
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: None,
13016                    }),
13017                )
13018                .unwrap()
13019        };
13020        let (probe_result, completion) = tokio::join!(probe, answer);
13021        let err = probe_result.expect_err("probe must miss its deadline");
13022        assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
13023        completion
13024    }
13025
13026    async fn time_out_without_answer(harness: &mut ProbeHarness) {
13027        let deadline = harness.runtime.health.deadline;
13028        let probe = probe_module_health(&harness.spec.module_id, &harness.runtime, None);
13029        let exhaust_deadline = async {
13030            let _frame = harness.module_rx.recv().await.expect("health.check frame");
13031            tokio::time::advance(deadline).await;
13032            tokio::task::yield_now().await;
13033        };
13034        let (probe_result, ()) = tokio::join!(probe, exhaust_deadline);
13035        let err = probe_result.expect_err("probe must miss its deadline");
13036        assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
13037    }
13038
13039    async fn run_probe_cycle(harness: &mut ProbeHarness, answer: bool) {
13040        let registry = Arc::clone(&harness.module.inner.registry);
13041        let snapshot = Arc::clone(&harness.module.inner.snapshot);
13042        let process_liveness = super::SupervisorProcessLiveness::default();
13043        let mut child = None;
13044        let cycle = super::run_health_probe_cycle(
13045            &harness.spec,
13046            &harness.runtime,
13047            &registry,
13048            &process_liveness,
13049            &snapshot,
13050            &mut child,
13051        );
13052        let peer = async {
13053            let frame = harness.module_rx.recv().await.expect("health.check frame");
13054            if answer {
13055                harness
13056                    .forwarding
13057                    .complete_module_control_rpc(
13058                        harness.module_connection,
13059                        frame.header.corr,
13060                        Some("health.check"),
13061                        ModuleControlRpcOutcome::Response(ModuleControlResponse::HealthCheck {
13062                            status: HealthStatus::Ok,
13063                            detail: None,
13064                            metrics: Some(serde_json::json!({"ready": true})),
13065                        }),
13066                    )
13067                    .unwrap();
13068            } else {
13069                tokio::time::advance(harness.runtime.health.deadline).await;
13070                tokio::task::yield_now().await;
13071            }
13072        };
13073        tokio::join!(cycle, peer);
13074    }
13075
13076    #[tokio::test(start_paused = true)]
13077    async fn unanswered_probe_is_unknown_until_threshold_and_ok_report_recovers() {
13078        let mut harness = probe_harness();
13079        // Drive the probe cycle directly with an in-memory wire peer. Stop the
13080        // disabled module's monitor so only this test owns lifecycle transitions;
13081        // no OS process is launched, and a restart is observed at scheduling.
13082        let monitor = harness.module.inner.monitor.lock().unwrap().take().unwrap();
13083        monitor.abort();
13084        let _ = monitor.await;
13085        super::update_snapshot(&harness.module.inner.snapshot, None, |state| {
13086            state.enabled = true;
13087            state.state = super::ModuleState::Running;
13088            state.process_alive = true;
13089        })
13090        .unwrap();
13091
13092        run_probe_cycle(&mut harness, true).await;
13093        assert_eq!(
13094            harness.module.status().unwrap().health.status,
13095            super::SupervisorHealthStatus::Ok
13096        );
13097
13098        for failures in 1..harness.runtime.health.failure_threshold {
13099            run_probe_cycle(&mut harness, false).await;
13100            let status = harness.module.status().unwrap();
13101            assert_eq!(status.health.status, super::SupervisorHealthStatus::Unknown);
13102            assert_eq!(status.health.consecutive_failures, failures);
13103            assert!(status.health.last_probe_ms.is_some());
13104            assert!(status.health.detail.unwrap().starts_with("[no-answer]"));
13105            assert!(status.health.metrics.is_none());
13106            assert_eq!(status.state, super::ModuleState::Running);
13107            assert!(status.process_alive);
13108            assert_eq!(status.restart_count, 0);
13109            assert_eq!(status.lifetime_restarts, 0);
13110            assert!(status.health.last_action.is_none());
13111            assert!(harness.runtime.scheduled_respawn.lock().unwrap().is_none());
13112        }
13113
13114        run_probe_cycle(&mut harness, true).await;
13115        let recovered = harness.module.status().unwrap();
13116        assert_eq!(recovered.health.status, super::SupervisorHealthStatus::Ok);
13117        assert_eq!(recovered.health.consecutive_failures, 0);
13118        assert!(recovered.health.detail.is_none());
13119        assert_eq!(
13120            recovered.health.metrics,
13121            Some(serde_json::json!({"ready": true}))
13122        );
13123        assert_eq!(recovered.lifetime_restarts, 0);
13124
13125        for failures in 1..=harness.runtime.health.failure_threshold {
13126            run_probe_cycle(&mut harness, false).await;
13127            let status = harness.module.status().unwrap();
13128            assert_eq!(status.health.consecutive_failures, failures);
13129            if failures < harness.runtime.health.failure_threshold {
13130                assert_eq!(status.health.status, super::SupervisorHealthStatus::Unknown);
13131                assert_eq!(status.state, super::ModuleState::Running);
13132                assert_eq!(status.lifetime_restarts, 0);
13133                assert!(harness.runtime.scheduled_respawn.lock().unwrap().is_none());
13134            } else {
13135                assert_eq!(
13136                    status.health.status,
13137                    super::SupervisorHealthStatus::Unresponsive
13138                );
13139                assert_eq!(status.state, super::ModuleState::Restarting);
13140                assert_eq!(status.restart_count, 1);
13141                assert_eq!(status.lifetime_restarts, 1);
13142                assert_eq!(status.health.last_action.as_deref(), Some("restart"));
13143                assert!(harness.runtime.scheduled_respawn.lock().unwrap().is_some());
13144            }
13145        }
13146    }
13147
13148    #[tokio::test(start_paused = true)]
13149    async fn late_health_answers_record_start_anchored_latency_for_two_stalls() {
13150        let mut harness = probe_harness();
13151
13152        let first = finish_after(&mut harness, Duration::from_secs(8)).await;
13153        let first_latency = match &first {
13154            ModuleControlRpcCompletion::LateHealthAnswer { latency, .. } => *latency,
13155            other => panic!("late answer was not retained: {other:?}"),
13156        };
13157        assert!(harness.handler.observe_module_control_completion(first));
13158
13159        let second = finish_after(&mut harness, Duration::from_secs(11)).await;
13160        let second_latency = match &second {
13161            ModuleControlRpcCompletion::LateHealthAnswer { latency, .. } => *latency,
13162            other => panic!("late answer was not retained: {other:?}"),
13163        };
13164        assert!(harness.handler.observe_module_control_completion(second));
13165
13166        assert_eq!(first_latency, Duration::from_secs(8));
13167        assert_eq!(
13168            second_latency - first_latency,
13169            Duration::from_secs(3),
13170            "latency must grow linearly with the additional stall"
13171        );
13172        let health = harness.module.status().unwrap().health;
13173        assert_eq!(health.late_answer_count, 2);
13174        assert_eq!(health.last_late_answer_latency_ms, Some(11_000));
13175    }
13176
13177    /// A module that answers every probe late must never march to the kill
13178    /// threshold: the late answer proves it is alive, so it must clear the miss
13179    /// streak the timeout recorded. Without the reset, a CPU-starved module
13180    /// that serves every probe seconds past the deadline accumulates
13181    /// `consecutive_failures` to the threshold and is killed — the exact
13182    /// sequence from the 2026-08-14 aft disable, where the daemon logged
13183    /// "proves the module is alive" five times while counting five misses.
13184    #[tokio::test(start_paused = true)]
13185    async fn late_answer_clears_the_consecutive_failure_streak() {
13186        let mut harness = probe_harness();
13187
13188        // Timeout recorded first: the probe path saw no answer in time.
13189        time_out_without_answer(&mut harness).await;
13190        harness
13191            .module
13192            .record_health_probe_failure_for_test("[no-answer] test miss")
13193            .unwrap();
13194        assert_eq!(
13195            harness.module.status().unwrap().health.consecutive_failures,
13196            1,
13197            "precondition: the miss must be on the streak before the late answer"
13198        );
13199
13200        // The stalled reply then lands: proof of life.
13201        let late = finish_after(&mut harness, Duration::from_secs(9)).await;
13202        assert!(matches!(
13203            late,
13204            ModuleControlRpcCompletion::LateHealthAnswer { .. }
13205        ));
13206        assert!(harness.handler.observe_module_control_completion(late));
13207
13208        let health = harness.module.status().unwrap().health;
13209        assert_eq!(
13210            health.consecutive_failures, 0,
13211            "a late answer is an answer: the streak must reset"
13212        );
13213        assert_eq!(health.late_answer_count, 1);
13214    }
13215
13216    #[tokio::test(start_paused = true)]
13217    async fn repeated_serial_probe_cycles_keep_one_tombstone_per_endpoint() {
13218        let mut harness = probe_harness();
13219
13220        for _ in 0..20 {
13221            time_out_without_answer(&mut harness).await;
13222            assert_eq!(
13223                harness.forwarding.health_probe_tombstone_count().unwrap(),
13224                1
13225            );
13226        }
13227    }
13228}
13229
13230#[cfg(test)]
13231mod child_env_tests {
13232    #[cfg(windows)]
13233    use super::{
13234        clear_current_process_facts, observed_launch_nonce_source, ModuleState, SupervisorSnapshot,
13235    };
13236    #[cfg(windows)]
13237    use std::sync::{Arc, OnceLock};
13238    #[cfg(windows)]
13239    #[test]
13240    fn windows_wire_spawn_prepares_pipe_and_keeps_phase_one_env() {
13241        let spec = spec(Vec::new());
13242        let mut command = Command::new("unused");
13243        let handoff = apply_wire_spawn_args(&mut command, &spec, None, None)
13244            .unwrap()
13245            .unwrap();
13246        let name = command
13247            .as_std()
13248            .get_envs()
13249            .find(|(key, _)| *key == OsStr::new(subc_os::launch_nonce::LAUNCH_NONCE_PIPE_ENV))
13250            .and_then(|(_, value)| value)
13251            .unwrap();
13252        assert_eq!(name, OsStr::new(handoff.name()));
13253        assert!(handoff.name().starts_with(r"\\.\pipe\subc-launch-"));
13254        assert!(command.as_std().get_envs().any(|(key, value)| key
13255            == OsStr::new(subc_os::LAUNCH_NONCE_PIPE_FALLBACK_ENV)
13256            && value == Some(OsStr::new("env"))));
13257        assert!(command
13258            .as_std()
13259            .get_envs()
13260            .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some()));
13261    }
13262
13263    #[cfg(windows)]
13264    #[test]
13265    fn windows_source_reporting_tracks_consumption_and_resets_per_spawn() {
13266        let mut snapshot = SupervisorSnapshot::new(ModuleState::Running, true);
13267        snapshot.process_alive = true;
13268        snapshot.spawned_protocol = Some(ModuleProtocol::Subc);
13269        assert_eq!(
13270            observed_launch_nonce_source(&snapshot).as_deref(),
13271            Some("env")
13272        );
13273        let consumed = Arc::new(OnceLock::new());
13274        snapshot.pipe_consumed = Some(consumed.clone());
13275        assert_eq!(
13276            observed_launch_nonce_source(&snapshot).as_deref(),
13277            Some("env")
13278        );
13279        consumed.set(()).unwrap();
13280        assert_eq!(
13281            observed_launch_nonce_source(&snapshot).as_deref(),
13282            Some("pipe")
13283        );
13284        snapshot.pipe_consumed = Some(Arc::new(OnceLock::new()));
13285        assert_eq!(
13286            observed_launch_nonce_source(&snapshot).as_deref(),
13287            Some("env")
13288        );
13289        clear_current_process_facts(&mut snapshot);
13290        assert_eq!(observed_launch_nonce_source(&snapshot), None);
13291    }
13292    use super::{
13293        apply_child_env, apply_spawn_role, apply_wire_spawn_args, ModuleProtocol, ModuleSpec,
13294        SpawnRole, SupervisorHandle, SPAWN_ROLE_SWAP_CANDIDATE, SUBC_ARG, SUBC_LAUNCH_NONCE_ENV,
13295        SUBC_MODULE_ID_ENV, SUBC_SPAWN_ROLE_ENV,
13296    };
13297    use std::{ffi::OsStr, path::PathBuf};
13298    use tokio::process::Command;
13299
13300    fn spec(env: Vec<(String, String)>) -> ModuleSpec {
13301        ModuleSpec {
13302            module_id: "env-plan".to_string(),
13303            program: PathBuf::from("/nonexistent"),
13304            args: Vec::new(),
13305            env,
13306            reserved: false,
13307            reserved_prefixes: Vec::new(),
13308            protocol: ModuleProtocol::Subc,
13309            overlap: Default::default(),
13310        }
13311    }
13312
13313    /// Ambient `CK_LOG` is REMOVED for an unconfigured module, and a configured
13314    /// one still gets its own.
13315    ///
13316    /// This is the narrow goal `env_clear()` was reached for, and the reason the
13317    /// fix is `env_remove` rather than deleting the line: an operator's ambient
13318    /// filter silently becoming an unconfigured module's log level is a real
13319    /// defect, just a much smaller one than clearing the environment.
13320    ///
13321    /// Asserted on the command plan rather than a spawned child because proving
13322    /// the ABSENCE of an inherited variable needs the parent's environment
13323    /// mutated, and `forbid(unsafe_code)` refuses that. `get_envs()` reports a
13324    /// removal as `(key, None)`, which is exactly the distinction wanted: not
13325    /// "absent because nobody set it" but "explicitly unset for the child".
13326    #[test]
13327    fn ambient_ck_log_is_removed_and_a_configured_one_survives() {
13328        let mut command = Command::new("/nonexistent");
13329        apply_child_env(&mut command, &spec(Vec::new()));
13330        let removed = command
13331            .as_std()
13332            .get_envs()
13333            .any(|(key, value)| key == OsStr::new("CK_LOG") && value.is_none());
13334        assert!(
13335            removed,
13336            "ambient CK_LOG must be explicitly removed for an unconfigured module"
13337        );
13338
13339        let mut configured = Command::new("/nonexistent");
13340        apply_child_env(
13341            &mut configured,
13342            &spec(vec![("CK_LOG".to_string(), "debug".to_string())]),
13343        );
13344        let effective = configured
13345            .as_std()
13346            .get_envs()
13347            .filter(|(key, _)| *key == OsStr::new("CK_LOG"))
13348            .last()
13349            .map(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()));
13350        assert_eq!(
13351            effective,
13352            Some(Some("debug".to_string())),
13353            "a module's configured CK_LOG must survive the ambient removal"
13354        );
13355    }
13356
13357    /// A `protocol: "none"` spawn carries NO `--subc` argument and NO launch
13358    /// nonce; a subc-wire spawn carries both. Asserted on the command plan for
13359    /// the same reason as the CK_LOG test above.
13360    ///
13361    /// The argument is the load-bearing half: a stock binary exits on an
13362    /// unknown flag before it listens, so with `--subc` appended the mode
13363    /// could not supervise the one process it exists for. Found by the first
13364    /// conformance run (nats-server: `flag provided but not defined: -subc`).
13365    #[test]
13366    fn protocol_none_spawn_carries_no_subc_argument_and_no_nonce() {
13367        let connection_file = std::path::Path::new("/run/subc-connection.json");
13368        let handle = SupervisorHandle::new();
13369
13370        let mut none_spec = spec(Vec::new());
13371        none_spec.protocol = ModuleProtocol::None;
13372        let mut none = Command::new("/nonexistent");
13373        let none_handoff =
13374            apply_wire_spawn_args(&mut none, &none_spec, Some(connection_file), Some(&handle))
13375                .expect("protocol-none spawn args apply");
13376        assert!(
13377            none_handoff.is_none(),
13378            "protocol:none spawn must not receive a nonce descriptor"
13379        );
13380        #[cfg(windows)]
13381        assert!(!none.as_std().get_envs().any(|(key, value)| key
13382            == OsStr::new(subc_os::LAUNCH_NONCE_PIPE_FALLBACK_ENV)
13383            && value.is_some()));
13384        assert!(
13385            !none.as_std().get_envs().any(|(key, value)| key
13386                == OsStr::new(subc_os::LAUNCH_NONCE_FD_ENV)
13387                && value.is_some()),
13388            "protocol:none spawn must not name a nonce descriptor"
13389        );
13390        let none_args: Vec<String> = none
13391            .as_std()
13392            .get_args()
13393            .map(|a| a.to_string_lossy().into_owned())
13394            .collect();
13395        assert!(
13396            !none_args.iter().any(|a| a == SUBC_ARG),
13397            "protocol:none argv must not carry --subc; got {none_args:?}"
13398        );
13399        let none_has_nonce = none
13400            .as_std()
13401            .get_envs()
13402            .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some());
13403        assert!(
13404            !none_has_nonce,
13405            "protocol:none spawn must not receive a launch nonce"
13406        );
13407        let none_has_module_id = none
13408            .as_std()
13409            .get_envs()
13410            .any(|(key, value)| key == OsStr::new(SUBC_MODULE_ID_ENV) && value.is_some());
13411        assert!(
13412            none_has_module_id,
13413            "SUBC_MODULE_ID is inert and stays on every path"
13414        );
13415        assert!(
13416            handle.spawn_nonce(&none_spec.module_id).is_none(),
13417            "no nonce record for a process that will never present one"
13418        );
13419
13420        // Control: the subc-wire path is unchanged by the branch above.
13421        let wire_spec = spec(Vec::new());
13422        let mut wire = Command::new("/nonexistent");
13423        let wire_handoff =
13424            apply_wire_spawn_args(&mut wire, &wire_spec, Some(connection_file), Some(&handle))
13425                .expect("subc-wire spawn args apply");
13426        let wire_fd_env = wire
13427            .as_std()
13428            .get_envs()
13429            .find(|(key, _)| *key == OsStr::new(subc_os::LAUNCH_NONCE_FD_ENV))
13430            .and_then(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()));
13431        #[cfg(unix)]
13432        assert_eq!(
13433            wire_fd_env,
13434            Some(wire_handoff.expect("a descriptor handoff").fd_env_value()),
13435            "a subc-wire spawn names the pipe it will receive at descriptor 3"
13436        );
13437        #[cfg(windows)]
13438        assert!(wire_handoff.is_some() && wire_fd_env.is_none());
13439        #[cfg(not(any(unix, windows)))]
13440        assert!(wire_handoff.is_none() && wire_fd_env.is_none());
13441        let wire_args: Vec<String> = wire
13442            .as_std()
13443            .get_args()
13444            .map(|a| a.to_string_lossy().into_owned())
13445            .collect();
13446        assert_eq!(
13447            wire_args,
13448            vec![
13449                SUBC_ARG.to_string(),
13450                connection_file.to_string_lossy().into_owned()
13451            ],
13452            "a subc-wire spawn still carries --subc <path>"
13453        );
13454        assert_eq!(
13455            wire.as_std()
13456                .get_envs()
13457                .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some()),
13458            !cfg!(unix),
13459            "only Windows supplies the environment nonce"
13460        );
13461        assert!(handle.spawn_nonce(&wire_spec.module_id).is_some());
13462    }
13463
13464    /// A plain spawn EXPLICITLY REMOVES the spawn role, even when the module's
13465    /// spec tries to set it; only a swap candidate carries it.
13466    ///
13467    /// "Set it only on candidates" is not enough, because spawn applies the
13468    /// spec's env verbatim and the daemon's own environment is inherited: either
13469    /// could hand a plain restart the swap role, and a module reading it would
13470    /// warm on its long swap budget while callers wait. Asserted as an explicit
13471    /// removal (`(key, None)`), not mere absence, for the reason the `CK_LOG`
13472    /// test above gives.
13473    #[test]
13474    fn plain_spawn_removes_the_spawn_role_even_when_the_spec_sets_it() {
13475        let role = |command: &Command| {
13476            command
13477                .as_std()
13478                .get_envs()
13479                .filter(|(key, _)| *key == OsStr::new(SUBC_SPAWN_ROLE_ENV))
13480                .last()
13481                .map(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()))
13482        };
13483        let forged = spec(vec![(
13484            SUBC_SPAWN_ROLE_ENV.to_string(),
13485            SPAWN_ROLE_SWAP_CANDIDATE.to_string(),
13486        )]);
13487
13488        let mut plain = Command::new("/nonexistent");
13489        apply_child_env(&mut plain, &forged);
13490        apply_spawn_role(&mut plain, SpawnRole::Plain);
13491        assert_eq!(
13492            role(&plain),
13493            Some(None),
13494            "a plain spawn must remove SUBC_SPAWN_ROLE, whatever the spec says"
13495        );
13496
13497        let mut candidate = Command::new("/nonexistent");
13498        apply_child_env(&mut candidate, &spec(Vec::new()));
13499        apply_spawn_role(&mut candidate, SpawnRole::SwapCandidate);
13500        assert_eq!(
13501            role(&candidate),
13502            Some(Some(SPAWN_ROLE_SWAP_CANDIDATE.to_string()))
13503        );
13504    }
13505
13506    /// Daemon-private capture retention keys never reach the child.
13507    ///
13508    /// cortexkit-log exposes retention as a Rust struct with no environment
13509    /// names, so these entries are supervisor metadata. Passing them through
13510    /// would invent a public child-process contract by accident.
13511    #[test]
13512    fn daemon_private_capture_keys_are_not_passed_to_the_child() {
13513        let mut command = Command::new("/nonexistent");
13514        apply_child_env(
13515            &mut command,
13516            &spec(vec![
13517                (super::CAPTURE_KEEP_ENV.to_string(), "5".to_string()),
13518                ("KEPT".to_string(), "yes".to_string()),
13519            ]),
13520        );
13521        let keys: Vec<String> = command
13522            .as_std()
13523            .get_envs()
13524            .filter(|(_, value)| value.is_some())
13525            .map(|(key, _)| key.to_string_lossy().into_owned())
13526            .collect();
13527        assert!(keys.contains(&"KEPT".to_string()), "got {keys:?}");
13528        assert!(
13529            !keys.contains(&super::CAPTURE_KEEP_ENV.to_string()),
13530            "daemon-private capture key leaked to the child: {keys:?}"
13531        );
13532    }
13533}
13534
13535#[cfg(test)]
13536mod jitter_tests {
13537    use super::jittered_health_delay;
13538    use std::{collections::HashSet, time::Duration};
13539
13540    /// Module ids drawn from a real fleet, so the dispersal claim is about names
13541    /// that actually occur rather than invented ones.
13542    ///
13543    /// This is a SAMPLE, not a registry: the property under test is that distinct
13544    /// ids disperse, which holds for any set of distinct strings. Several entries
13545    /// are already historical (modules get renamed), and that costs nothing here --
13546    /// but it means a reader must not mistake this for the live module set, and a
13547    /// rename sweep will match it without there being anything to change.
13548    const FLEET: [&str; 14] = [
13549        "aft",
13550        "alfonso-core",
13551        "magic-context",
13552        "broca",
13553        "thalamus",
13554        "quota",
13555        "engram",
13556        "plexus",
13557        "cerebellum",
13558        "astrocyte",
13559        "synapse",
13560        "subc-mcp",
13561        "cortexkit-credentials",
13562        "subc-federation",
13563    ];
13564
13565    /// Probes must not converge after a fleet-wide restart.
13566    ///
13567    /// This is the property the jitter exists for: every module reconnects at
13568    /// once, and without dispersal all fourteen would then probe on the same
13569    /// tick forever. Nothing failed visibly when this went untested -- a
13570    /// convergent fleet still probes correctly, just in a burst, so the symptom
13571    /// is a periodic load spike that looks like whatever else is running.
13572    #[test]
13573    fn probe_delays_disperse_across_the_fleet() {
13574        let cadence = Duration::from_secs(30);
13575        let delays: HashSet<Duration> = FLEET
13576            .iter()
13577            .map(|id| jittered_health_delay(id, 0, cadence))
13578            .collect();
13579        assert_eq!(
13580            delays.len(),
13581            FLEET.len(),
13582            "every supervised module must land on its own probe offset"
13583        );
13584    }
13585
13586    /// The offset may only ever DELAY a probe, never bring it forward.
13587    ///
13588    /// A delay below the cadence would probe a module more often than
13589    /// configured, which is the opposite of what an operator asked for and
13590    /// would tighten the failure budget without anyone changing it.
13591    #[test]
13592    fn jitter_only_delays_and_stays_within_one_tenth_of_cadence() {
13593        let cadence = Duration::from_secs(30);
13594        let span = cadence / 10;
13595        for id in FLEET {
13596            for probe_index in 0..8 {
13597                let delay = jittered_health_delay(id, probe_index, cadence);
13598                assert!(
13599                    delay >= cadence,
13600                    "{id}#{probe_index}: jitter must not shorten the cadence"
13601                );
13602                assert!(
13603                    delay < cadence + span,
13604                    "{id}#{probe_index}: jitter must stay inside one tenth of the cadence"
13605                );
13606            }
13607        }
13608    }
13609
13610    /// A module keeps its offset across daemon restarts.
13611    ///
13612    /// The delay is derived rather than randomised precisely so a restart does
13613    /// not re-roll every module into a fresh chance of collision. A random
13614    /// source would satisfy the dispersal test above and quietly lose this.
13615    #[test]
13616    fn a_module_offset_is_stable_across_restarts() {
13617        let cadence = Duration::from_secs(30);
13618        for id in FLEET {
13619            assert_eq!(
13620                jittered_health_delay(id, 0, cadence),
13621                jittered_health_delay(id, 0, cadence),
13622                "{id}: the same module and probe index must produce the same offset"
13623            );
13624        }
13625    }
13626
13627    /// A zero cadence disables probing rather than producing a busy loop.
13628    #[test]
13629    fn zero_cadence_yields_zero_delay() {
13630        assert_eq!(
13631            jittered_health_delay("aft", 0, Duration::ZERO),
13632            Duration::ZERO
13633        );
13634    }
13635}
13636
13637#[cfg(all(test, target_os = "linux"))]
13638mod cgroup_placement_tests {
13639    use super::{
13640        apply_cgroup_placement, remove_module_cgroup, ModuleProtocol, ModuleSpec, SuperviseError,
13641        SupervisedChild,
13642    };
13643    use crate::stderr_tail::{StderrRing, StderrTailConfig};
13644    use cortexkit_test_support::ScratchDir;
13645    use std::{
13646        fs, io,
13647        path::{Path, PathBuf},
13648        sync::{Arc, Mutex},
13649    };
13650    use tokio::process::Command;
13651
13652    #[tokio::test]
13653    async fn unique_spawn_cgroups_clean_up_across_restarts_and_shutdown() {
13654        use super::*;
13655        let dir = ScratchDir::new("unique-spawn-cgroups");
13656        let root = PathBuf::from(format!(
13657            "/sys/fs/cgroup/subc-unique-test-{}-{}",
13658            std::process::id(),
13659            unix_ms_now()
13660        ));
13661        if let Err(error) = fs::create_dir(&root) {
13662            assert!(
13663                std::env::var_os("SUBC_REQUIRE_CGROUP_TEST").is_none(),
13664                "required cgroup test cannot execute: {error}"
13665            );
13666            eprintln!("SKIP unique_spawn_cgroups_clean_up_across_restarts_and_shutdown: {error}");
13667            return;
13668        }
13669        let placement = subc_cgroup::prepare_at(&root).unwrap().unwrap();
13670        let group_count = || {
13671            fs::read_dir(root.join("subc-modules"))
13672                .unwrap()
13673                .map(|entry| entry.unwrap().file_type().unwrap())
13674                .filter(|kind| kind.is_dir())
13675                .count()
13676        };
13677        let supervisor =
13678            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
13679                .with_cgroup_placement(Some(placement.clone()));
13680        let runtime = supervisor.runtime_config();
13681        let mut spec = ModuleSpec {
13682            module_id: "unique-spawn".into(),
13683            program: PathBuf::from("/bin/sleep"),
13684            args: vec!["60".into()],
13685            env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
13686                .into_iter()
13687                .map(|key| (key.into(), dir.to_string_lossy().into_owned()))
13688                .collect(),
13689            reserved: false,
13690            reserved_prefixes: vec![],
13691            protocol: ModuleProtocol::None,
13692            overlap: Default::default(),
13693        };
13694        let spawn = |spec: &ModuleSpec| {
13695            spawn_child(
13696                spec,
13697                None,
13698                None,
13699                &runtime.stderr_ring,
13700                None,
13701                &runtime.child_roster,
13702                Some(&placement),
13703            )
13704            .unwrap()
13705        };
13706        let mut live = spawn(&spec);
13707        for _ in 0..3 {
13708            // A new process can enter the old slot while retirement is pending.
13709            let next = spawn(&spec);
13710            assert_ne!(live.module_id, next.module_id);
13711            live.start_kill().unwrap();
13712            live.wait().await.unwrap();
13713            live = next;
13714            assert!(
13715                live.child.try_wait().unwrap().is_none(),
13716                "retiring the old slot must not kill the replacement"
13717            );
13718            assert_eq!(
13719                group_count(),
13720                1,
13721                "only the live spawn's cgroup should remain"
13722            );
13723        }
13724        supervisor.begin_daemon_shutdown();
13725        let reap = tokio::spawn(async move {
13726            live.wait().await.unwrap();
13727        });
13728        supervisor
13729            .end_children_for_daemon_shutdown(false, std::future::pending())
13730            .await;
13731        reap.await.unwrap();
13732        assert_eq!(group_count(), 0);
13733        // A normal exit uses the same tree-cleanup path as a killed spawn.
13734        spec.program = PathBuf::from("/bin/true");
13735        spec.args.clear();
13736        let fresh_roster = ChildRoster::default();
13737        let mut short = spawn_child(
13738            &spec,
13739            None,
13740            None,
13741            &runtime.stderr_ring,
13742            None,
13743            &fresh_roster,
13744            Some(&placement),
13745        )
13746        .unwrap();
13747        short.wait().await.unwrap();
13748        assert_eq!(group_count(), 0);
13749        spec.module_id = "_".repeat(255);
13750        let mut long_id = spawn_child(
13751            &spec,
13752            None,
13753            None,
13754            &runtime.stderr_ring,
13755            None,
13756            &fresh_roster,
13757            Some(&placement),
13758        )
13759        .unwrap();
13760        long_id.wait().await.unwrap();
13761        assert_eq!(
13762            group_count(),
13763            0,
13764            "valid long module IDs must not exceed cgroup NAME_MAX"
13765        );
13766        fs::remove_dir(root.join("subc-modules")).unwrap();
13767        fs::remove_dir(root).unwrap();
13768        eprintln!("EXECUTED unique_spawn_cgroups_clean_up_across_restarts_and_shutdown");
13769    }
13770
13771    #[test]
13772    fn failed_parent_cgroup_open_is_a_cgroup_supervision_error() {
13773        let path = Path::new("/definitely-missing-subc-cgroup");
13774        let mut command = Command::new("true");
13775        let error = apply_cgroup_placement(
13776            &mut command,
13777            &ModuleSpec {
13778                module_id: "broken-cgroup".to_string(),
13779                program: PathBuf::from("true"),
13780                args: Vec::new(),
13781                env: Vec::new(),
13782                reserved: false,
13783                reserved_prefixes: Vec::new(),
13784                protocol: ModuleProtocol::Subc,
13785                overlap: Default::default(),
13786            },
13787            path,
13788        )
13789        .expect_err("a parent cgroup open failure must reject the supervised spawn");
13790        let reason = error.to_string();
13791
13792        assert!(
13793            matches!(error, SuperviseError::Cgroup { .. }),
13794            "parent cgroup open must be reported as a cgroup supervision error: {reason}"
13795        );
13796        assert!(
13797            reason.contains("/definitely-missing-subc-cgroup/cgroup.procs"),
13798            "parent cgroup open failure must name cgroup.procs: {reason}"
13799        );
13800    }
13801
13802    #[tokio::test]
13803    async fn reaping_a_child_removes_its_empty_module_cgroup() {
13804        let root = ScratchDir::new("supervisor-reap-cgroup");
13805        fs::write(root.join("cgroup.procs"), b"").expect("write scratch cgroup marker");
13806        let placement = subc_cgroup::prepare_at(&root)
13807            .expect("prepare scratch cgroup root")
13808            .expect("scratch root has a cgroup.procs marker");
13809        let module_id = "reaped-module";
13810        let module = placement
13811            .module_path(module_id)
13812            .expect("create scratch module cgroup");
13813        let child = Command::new("true")
13814            .env("XDG_DATA_HOME", root.path())
13815            .env("XDG_RUNTIME_DIR", root.path())
13816            .env("XDG_CONFIG_HOME", root.path())
13817            .spawn()
13818            .expect("spawn short-lived child");
13819        let pid = child.id().expect("spawned child has pid");
13820        let mut child = SupervisedChild {
13821            child,
13822            protocol: ModuleProtocol::Subc,
13823            module_id: module_id.to_string(),
13824            cgroup_placement: Some(placement),
13825            stdout_pump: None,
13826            stderr_pump: None,
13827            stderr_ring: Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default()))),
13828            spawned_at_ms: 0,
13829            spawned_from: PathBuf::from("true"),
13830            spawned_file_identity: None,
13831            process_start_time: None,
13832            process_identity: None,
13833            pid,
13834            roster_guard: None,
13835            #[cfg(target_os = "macos")]
13836            privacy_exec: None,
13837            spawn_failure: None,
13838        };
13839
13840        child.wait().await.expect("reap short-lived child");
13841
13842        assert!(
13843            !module.exists(),
13844            "reaping the supervised child must remove its empty cgroup"
13845        );
13846    }
13847
13848    #[test]
13849    fn non_empty_cgroup_removal_is_reported_without_blocking_teardown() {
13850        let root = ScratchDir::new("supervisor-non-empty-cgroup");
13851        fs::write(root.join("cgroup.procs"), b"").expect("write scratch cgroup marker");
13852        let placement = subc_cgroup::prepare_at(&root)
13853            .expect("prepare scratch cgroup root")
13854            .expect("scratch root has a cgroup.procs marker");
13855        let module = placement
13856            .module_path("surviving-module")
13857            .expect("create scratch module cgroup");
13858        fs::write(module.join("surviving-process"), b"still present")
13859            .expect("make scratch cgroup non-empty");
13860        let (logs, _guard) = crate::router::test_log::log_capture(tracing::Level::WARN);
13861
13862        remove_module_cgroup(&placement, "surviving-module");
13863
13864        let logs = crate::router::test_log::captured_logs(&logs);
13865        assert!(
13866            module.exists(),
13867            "failed removal must leave the cgroup intact"
13868        );
13869        assert!(
13870            logs.contains("could not remove module cgroup after process exit; continuing teardown")
13871                && logs.contains("surviving-module"),
13872            "best-effort removal must report the failure without returning it: {logs}"
13873        );
13874    }
13875
13876    #[test]
13877    fn cgroup_pre_exec_spawn_failure_names_the_cgroup_path() {
13878        let cgroup_path = PathBuf::from("/sys/fs/cgroup/subc-modules/broken-module");
13879        let reason = SuperviseError::Spawn {
13880            program: PathBuf::from("/bin/true"),
13881            source: io::Error::from_raw_os_error(13),
13882            cgroup_path: Some(cgroup_path.clone()),
13883        }
13884        .to_string();
13885
13886        assert!(
13887            reason.contains(&cgroup_path.display().to_string()),
13888            "a pre_exec spawn failure must name the cgroup path: {reason}"
13889        );
13890    }
13891}
13892
13893#[cfg(test)]
13894mod spawn_subscriber_lag_tests {
13895    use super::*;
13896
13897    /// A subscriber whose connection stops draining is dropped once its frame
13898    /// channel fills. The client must learn that from a terminal Error frame
13899    /// after the frames already queued for it, not from a stream that simply
13900    /// goes quiet.
13901    #[tokio::test]
13902    async fn lagged_spawn_subscriber_receives_a_terminal_lagged_error_after_its_queued_frames() {
13903        let feed = SpawnEventFeed::default();
13904        feed.configure_incarnation("lag-incarnation".to_string());
13905        // A one-slot connection queue that nobody reads until the emits are
13906        // done: the forwarder parks on it and the subscriber channel fills.
13907        let (tx, mut rx) = mpsc::channel(1);
13908        feed.subscribe(ConnectionId::new(1), 7, 1, None, FrameSink::new(tx))
13909            .expect("subscribe");
13910        let emitted = SPAWN_SUBSCRIBER_BUFFER + 16;
13911        for index in 0..emitted {
13912            feed.emit_spawned(&format!("lag-module-{index}"), 1000, 0);
13913            // Let the forwarder take what it can so the fill point is the
13914            // subscriber channel, not a scheduling accident.
13915            tokio::task::yield_now().await;
13916        }
13917        assert_eq!(
13918            feed.subscriber_count(),
13919            0,
13920            "the lagged subscriber must be removed"
13921        );
13922
13923        let mut data = Vec::new();
13924        let mut last = None;
13925        loop {
13926            let next = tokio::time::timeout(Duration::from_secs(5), rx.recv())
13927                .await
13928                .expect("the forwarder must finish once the subscriber is dropped");
13929            let Some(outbound) = next else { break };
13930            let frame = outbound.frame;
13931            if frame.header.ty == FrameType::StreamData {
13932                assert!(last.is_none(), "no data may follow the terminal frame");
13933                let event: SpawnEvent = serde_json::from_slice(&frame.body).unwrap();
13934                data.push(event.cursor.seq);
13935            } else {
13936                assert!(last.is_none(), "exactly one terminal frame");
13937                last = Some(frame);
13938            }
13939        }
13940        assert!(!data.is_empty(), "queued frames drain before the terminal");
13941        for pair in data.windows(2) {
13942            assert_eq!(
13943                pair[1],
13944                pair[0] + 1,
13945                "queued frames arrive dense and in order"
13946            );
13947        }
13948        let terminal = last.expect("a lagged subscriber must receive a terminal frame");
13949        assert_eq!(terminal.header.ty, FrameType::Error);
13950        assert_eq!(terminal.header.corr, 7);
13951        let body: subc_protocol::ErrorBody = serde_json::from_slice(&terminal.body).unwrap();
13952        assert_eq!(body.code, SPAWN_SUBSCRIBER_LAGGED_CODE);
13953        let detail = body.detail.expect("lagged error carries detail");
13954        assert_eq!(
13955            detail["first_undelivered_cursor"]["seq"],
13956            data.last().unwrap() + 1,
13957            "the named cursor is the first event the subscriber did not receive"
13958        );
13959        assert_eq!(
13960            detail["first_undelivered_cursor"]["daemon_incarnation"],
13961            "lag-incarnation"
13962        );
13963    }
13964}
13965
13966#[cfg(test)]
13967mod terminal_history_read_concurrency_tests {
13968    use super::*;
13969    use crate::terminal_journal::read_pause;
13970    use cortexkit_test_support::ScratchDir;
13971    use std::sync::mpsc as std_mpsc;
13972
13973    fn journaled_ring(
13974        journal: &Arc<crate::terminal_journal::TerminalJournal>,
13975    ) -> Arc<Mutex<TerminalRing>> {
13976        Arc::new(Mutex::new(
13977            TerminalRing::new(TerminalRingConfig::default(), 1)
13978                .with_journal(Some(Arc::clone(journal))),
13979        ))
13980    }
13981
13982    fn crash(at_ms: u64) -> ExitReport {
13983        ExitReport {
13984            kind: ExitKind::Crash,
13985            code: Some(1),
13986            signal: None,
13987            at_ms,
13988        }
13989    }
13990
13991    /// Record an exit on another thread and report whether it finished within
13992    /// `bound`. The recorder thread is left running if it did not.
13993    fn record_within(
13994        module_id: &'static str,
13995        ring: &Arc<Mutex<TerminalRing>>,
13996        at_ms: u64,
13997        bound: Duration,
13998    ) -> bool {
13999        let ring = Arc::clone(ring);
14000        let (done, done_rx) = std_mpsc::channel();
14001        std::thread::spawn(move || {
14002            record_terminal(
14003                module_id,
14004                &ring,
14005                &SpawnEventFeed::default(),
14006                &crash(at_ms),
14007                TerminalDisposition::Restarting,
14008            );
14009            let _ = done.send(());
14010        });
14011        done_rx.recv_timeout(bound).is_ok()
14012    }
14013
14014    /// A history read in progress must not hold the journal writer (which every
14015    /// module's exit recording needs) or the module's own ring. Exits recorded
14016    /// while the read is paused complete promptly; the paused read answers as of
14017    /// the moment it started, and the next read has each exit exactly once.
14018    #[test]
14019    fn exits_recorded_during_a_paused_history_read_are_not_blocked_or_half_merged() {
14020        let dir = ScratchDir::new("terminal-history-concurrent-read");
14021        let path = dir.join("terminals.jsonl");
14022        let journal = Arc::new(crate::terminal_journal::TerminalJournal::open(
14023            path.clone(),
14024            "daemon".into(),
14025        ));
14026        let reader_ring = journaled_ring(&journal);
14027        let other_ring = journaled_ring(&journal);
14028        assert!(record_within(
14029            "reader-module",
14030            &reader_ring,
14031            10,
14032            Duration::from_secs(5)
14033        ));
14034
14035        let (started, release) = read_pause::install(&path);
14036        let reading = {
14037            let ring = Arc::clone(&reader_ring);
14038            std::thread::spawn(move || durable_terminal_history_of(&ring, "reader-module"))
14039        };
14040        started
14041            .recv_timeout(Duration::from_secs(5))
14042            .expect("the history read reached its pause");
14043
14044        let bound = Duration::from_secs(1);
14045        assert!(
14046            record_within("other-module", &other_ring, 20, bound),
14047            "another module's exit waited on a history read (journal writer held)"
14048        );
14049        assert!(
14050            record_within("reader-module", &reader_ring, 30, bound),
14051            "the read module's own exit waited on its history read (ring held)"
14052        );
14053
14054        drop(release);
14055        let paused = reading.join().unwrap();
14056        assert_eq!(
14057            paused.entries.iter().map(|e| e.at_ms).collect::<Vec<_>>(),
14058            vec![10],
14059            "an exit recorded after the read began lands in neither half of it"
14060        );
14061        assert_eq!(paused.journal_skipped_lines, 0);
14062        assert_eq!(paused.journal_read_errors, 0);
14063
14064        let after = durable_terminal_history_of(&reader_ring, "reader-module");
14065        assert_eq!(
14066            after.entries.iter().map(|e| e.at_ms).collect::<Vec<_>>(),
14067            vec![10, 30],
14068            "the next read merges ring and journal with no duplicate"
14069        );
14070        assert_eq!(after.journal_skipped_lines, 0);
14071    }
14072}
14073
14074/// What a restart does with the exited process's output readers. These drive
14075/// the same settle functions the supervisor calls, with a reader the test
14076/// holds, so a reader that has not been scheduled by the bound is a controlled
14077/// input rather than something only a loaded machine produces.
14078#[cfg(test)]
14079mod stderr_settle_tests {
14080    use std::{
14081        future::Future,
14082        io,
14083        pin::Pin,
14084        sync::{Arc, Mutex},
14085        task::{Context, Poll},
14086        time::Duration,
14087    };
14088
14089    use tokio::{
14090        io::{AsyncRead, ReadBuf},
14091        sync::oneshot,
14092        time::Instant,
14093    };
14094
14095    use super::{settle_stderr_pump, settle_stdout_pump, StderrPump};
14096    use crate::stderr_tail::{
14097        pump_stderr_to, pump_stdout_to, split_capture_stamp, untimed, CaptureState,
14098        ChildOutputSink, OutputSink, StderrRing, StderrTailConfig, TailEntry,
14099    };
14100
14101    const BOUND: Duration = Duration::from_millis(250);
14102
14103    /// Yields `before`, then stays pending until the gate is released, then
14104    /// yields `after` and reaches EOF. The bytes after the gate were written
14105    /// by a process that has already exited; only the reader is behind.
14106    struct HeldReader {
14107        before: Option<Vec<u8>>,
14108        gate: Option<oneshot::Receiver<()>>,
14109        after: io::Cursor<Vec<u8>>,
14110    }
14111
14112    impl AsyncRead for HeldReader {
14113        fn poll_read(
14114            mut self: Pin<&mut Self>,
14115            cx: &mut Context<'_>,
14116            buf: &mut ReadBuf<'_>,
14117        ) -> Poll<io::Result<()>> {
14118            if let Some(bytes) = self.before.take() {
14119                buf.put_slice(&bytes);
14120                return Poll::Ready(Ok(()));
14121            }
14122            if let Some(gate) = self.gate.as_mut() {
14123                match Pin::new(gate).poll(cx) {
14124                    Poll::Pending => return Poll::Pending,
14125                    Poll::Ready(_) => self.gate = None,
14126                }
14127            }
14128            Pin::new(&mut self.after).poll_read(cx, buf)
14129        }
14130    }
14131
14132    struct DiscardSink;
14133
14134    impl OutputSink for DiscardSink {
14135        fn write_line(&mut self, _line: &[u8]) {}
14136    }
14137
14138    fn line(text: &str) -> TailEntry {
14139        TailEntry::Line {
14140            text: text.to_string(),
14141            truncated: false,
14142            at_ms: None,
14143        }
14144    }
14145
14146    fn lock(ring: &Arc<Mutex<StderrRing>>) -> std::sync::MutexGuard<'_, StderrRing> {
14147        ring.lock().unwrap()
14148    }
14149
14150    /// Start a reader for a new process generation that delivers `before`
14151    /// immediately and `after` only once the returned sender fires (or is
14152    /// dropped).
14153    fn held_pump(
14154        ring: &Arc<Mutex<StderrRing>>,
14155        before: &str,
14156        after: &str,
14157    ) -> (StderrPump, oneshot::Sender<()>) {
14158        let generation = lock(ring).begin_process();
14159        let (release, gate) = oneshot::channel();
14160        let reader = HeldReader {
14161            before: Some(before.as_bytes().to_vec()),
14162            gate: Some(gate),
14163            after: io::Cursor::new(after.as_bytes().to_vec()),
14164        };
14165        let task = tokio::spawn(pump_stderr_to(
14166            reader,
14167            Arc::clone(ring),
14168            generation,
14169            DiscardSink,
14170        ));
14171        (StderrPump { task, generation }, release)
14172    }
14173
14174    async fn wait_until(ring: &Arc<Mutex<StderrRing>>, done: impl Fn(&StderrRing) -> bool) {
14175        for _ in 0..1000 {
14176            if done(&lock(ring)) {
14177                return;
14178            }
14179            tokio::time::sleep(Duration::from_millis(1)).await;
14180        }
14181        panic!(
14182            "ring never reached the expected state: {:?}",
14183            lock(ring).snapshot(None, None)
14184        );
14185    }
14186
14187    #[tokio::test(start_paused = true)]
14188    async fn a_crash_line_the_reader_had_not_reached_by_the_bound_is_kept_before_the_restart() {
14189        let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
14190        let (pump, release) = held_pump(&ring, "booting\n", "config error: missing storage\n");
14191
14192        settle_stderr_pump("crasher", &ring, pump, BOUND).await;
14193        let before_release = lock(&ring).snapshot(None, None);
14194        assert!(
14195            matches!(before_release.capture, CaptureState::Incomplete { .. }),
14196            "a reader that has not reached EOF cannot claim a whole tail: {before_release:?}"
14197        );
14198
14199        // The restart: the next process starts and writes before the old
14200        // reader catches up.
14201        let next = lock(&ring).begin_process();
14202        lock(&ring).push_line_from(next, "next process booting");
14203        release.send(()).unwrap();
14204        wait_until(&ring, |ring| {
14205            ring.snapshot(None, None).capture == CaptureState::Captured
14206        })
14207        .await;
14208
14209        assert_eq!(
14210            untimed(lock(&ring).snapshot(None, None).entries),
14211            vec![
14212                line("booting"),
14213                line("config error: missing storage"),
14214                TailEntry::ProcessStart,
14215                line("next process booting"),
14216            ],
14217            "the crash's last line must survive a slow reader and stay in the crashed process's section"
14218        );
14219    }
14220
14221    #[tokio::test(start_paused = true)]
14222    async fn a_pipe_held_open_by_a_descendant_reads_incomplete_without_delaying_the_restart_past_the_bound(
14223    ) {
14224        let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
14225        // `_held` is never fired: a descendant keeps the pipe open for the
14226        // whole test.
14227        let (pump, _held) = held_pump(&ring, "parent exiting\n", "");
14228
14229        let started = Instant::now();
14230        settle_stderr_pump("orphaning", &ring, pump, BOUND).await;
14231        assert_eq!(
14232            started.elapsed(),
14233            BOUND,
14234            "the restart must wait exactly the bound for a pipe that stays open, no longer"
14235        );
14236
14237        let next = lock(&ring).begin_process();
14238        lock(&ring).push_line_from(next, "next process booting");
14239        tokio::time::sleep(Duration::from_secs(60)).await;
14240
14241        let snapshot = lock(&ring).snapshot(None, None);
14242        match &snapshot.capture {
14243            CaptureState::Incomplete { reason } => assert!(
14244                reason.contains("had not reached EOF") && reason.contains("250ms"),
14245                "the reason must say what is missing and after how long: {reason}"
14246            ),
14247            other => panic!("expected Incomplete while the pipe is held open, got {other:?}"),
14248        }
14249        assert_eq!(
14250            untimed(snapshot.entries),
14251            vec![
14252                line("parent exiting"),
14253                TailEntry::ProcessStart,
14254                line("next process booting"),
14255            ]
14256        );
14257    }
14258
14259    #[tokio::test(start_paused = true)]
14260    async fn a_reader_that_reaches_eof_within_the_bound_leaves_the_tail_captured() {
14261        let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
14262        let (pump, release) = held_pump(&ring, "one\n", "two\n");
14263        release.send(()).unwrap();
14264
14265        settle_stderr_pump("clean", &ring, pump, BOUND).await;
14266
14267        let snapshot = lock(&ring).snapshot(None, None);
14268        assert_eq!(snapshot.capture, CaptureState::Captured);
14269        assert_eq!(untimed(snapshot.entries), vec![line("one"), line("two")]);
14270    }
14271
14272    #[tokio::test(start_paused = true)]
14273    async fn stdout_buffered_past_the_exit_drain_bound_reaches_the_capture_file() {
14274        const BURST: usize = 150;
14275        let capture = cortexkit_test_support::ScratchDir::new("stdout-exit-drain");
14276        let path = capture.join("two-pipe.stderr.log");
14277        let sink = ChildOutputSink::open(&path, cortexkit_log::Retention::default()).unwrap();
14278        let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
14279        let generation = lock(&ring).begin_process();
14280        let stdout = (0..BURST)
14281            .map(|i| format!("out-{i:04}-{}\n", "o".repeat(64)))
14282            .collect::<String>();
14283        let stderr = (0..BURST)
14284            .map(|i| format!("err-{i:04}-{}\n", "e".repeat(64)))
14285            .collect::<String>();
14286        // Model a child that has written both bursts and exited. Hold the stdout
14287        // reader after one line and a partial prefix, as if pipe readiness or
14288        // capture-file writes had delayed it. The final line also needs EOF to
14289        // flush: neither complete nor partial buffered output may be discarded.
14290        let first_chunk = stdout.find('\n').unwrap() + 1 + "out-0001-".len();
14291        let (release, gate) = oneshot::channel();
14292        let reader = HeldReader {
14293            before: Some(stdout.as_bytes()[..first_chunk].to_vec()),
14294            gate: Some(gate),
14295            after: io::Cursor::new(stdout.as_bytes()[first_chunk..stdout.len() - 1].to_vec()),
14296        };
14297        let (finished, completion) = oneshot::channel();
14298        let stdout_sink = sink.clone();
14299        let pump = tokio::spawn(async move {
14300            pump_stdout_to(reader, stdout_sink).await;
14301            finished.send(()).unwrap();
14302        });
14303        pump_stderr_to(io::Cursor::new(stderr.into_bytes()), ring, generation, sink).await;
14304
14305        let started = Instant::now();
14306        settle_stdout_pump("two-pipe", pump, BOUND).await;
14307        assert_eq!(
14308            started.elapsed(),
14309            BOUND,
14310            "a late reader must not hold up restart"
14311        );
14312        assert_eq!(
14313            std::fs::read_to_string(&path).unwrap().lines().count(),
14314            BURST + 1
14315        );
14316
14317        release
14318            .send(())
14319            .expect("stdout reader must survive the exit drain bound");
14320        tokio::time::timeout(Duration::from_secs(1), completion)
14321            .await
14322            .expect("released stdout reader must reach EOF")
14323            .expect("stdout capture must finish at EOF, not be cancelled");
14324        let contents = std::fs::read_to_string(&path).unwrap();
14325        let mut lines = contents
14326            .lines()
14327            .map(|line| split_capture_stamp(line).expect("capture stamp").1)
14328            .collect::<Vec<_>>();
14329        let mut expected = (0..BURST)
14330            .flat_map(|i| {
14331                [
14332                    format!("out-{i:04}-{}", "o".repeat(64)),
14333                    format!("err-{i:04}-{}", "e".repeat(64)),
14334                ]
14335            })
14336            .collect::<Vec<_>>();
14337        lines.sort_unstable();
14338        expected.sort_unstable();
14339        assert_eq!(
14340            lines, expected,
14341            "both pipes must retain every complete line exactly once"
14342        );
14343    }
14344}
14345
14346/// Containment of a module's process tree (issue #109).
14347///
14348/// The behaviour these defend against is a module helper surviving its module:
14349/// on a real machine the Synapse embedding module's CUDA worker holds ~2.2 GB of
14350/// VRAM, so a leaked grandchild is a leaked GPU allocation, and a day of restarts
14351/// compounds it.
14352///
14353/// They run against the SUPERVISOR rather than the job-object crate because the
14354/// claim is about teardown: a crate-level test proves a job can reap a tree, not
14355/// that the daemon's drain path reaches it.
14356///
14357/// Windows-only, like the mechanism. On Unix this arm compiles out; the cgroup
14358/// lane there is a separate containment path with its own tests.
14359#[cfg(all(test, windows))]
14360mod job_containment_tests {
14361    use super::*;
14362    use cortexkit_test_support::ScratchDir;
14363    use std::{
14364        path::{Path, PathBuf},
14365        sync::{Arc, Mutex},
14366        time::{Duration, Instant},
14367    };
14368
14369    /// The stub, expected beside this test executable.
14370    ///
14371    /// The existence check is here for the reason its twin at `fake_aft_stub_path`
14372    /// documents: `--lib` does not build `[[bin]]` targets, and a bare spawn
14373    /// failure then reads as a broken test rather than an unbuilt dependency.
14374    fn stub_path() -> PathBuf {
14375        let mut path = std::env::current_exe().expect("current_exe available in tests");
14376        path.pop();
14377        path.pop();
14378        path.push("fake-aft-stub.exe");
14379        assert!(
14380            path.exists(),
14381            "fake-aft-stub not built at {}: run `cargo test -p subc-core` (which builds \
14382             [[bin]] targets) rather than `cargo test -p subc-core --lib` (which does not)",
14383            path.display()
14384        );
14385        path
14386    }
14387
14388    /// Poll for the grandchild pid the stub records, and parse it.
14389    fn read_grandchild_pid(path: &Path) -> u32 {
14390        let deadline = Instant::now() + Duration::from_secs(10);
14391        loop {
14392            if let Ok(contents) = std::fs::read_to_string(path) {
14393                if let Ok(pid) = contents.trim().parse() {
14394                    return pid;
14395                }
14396            }
14397            assert!(
14398                Instant::now() < deadline,
14399                "the stub never recorded a grandchild pid at {}",
14400                path.display()
14401            );
14402            std::thread::sleep(Duration::from_millis(10));
14403        }
14404    }
14405
14406    /// Everything one fixture run needs, so the two tests below differ in exactly
14407    /// one place: whether the child is contained.
14408    struct Fixture {
14409        _dir: ScratchDir,
14410        module_id: String,
14411        grandchild: u32,
14412        child: Option<SupervisedChild>,
14413        registry: Arc<Registry>,
14414        snapshot: Arc<Mutex<SupervisorSnapshot>>,
14415        terminal_ring: Arc<Mutex<TerminalRing>>,
14416        spawn_events: SpawnEventFeed,
14417    }
14418
14419    fn fixture(label: &str, module_id: &str) -> Fixture {
14420        let dir = ScratchDir::new(label);
14421        let pid_file = dir.join("grandchild.pid");
14422        let supervisor = Supervisor::new_for_test(
14423            Arc::new(Registry::default()),
14424            RestartPolicy::new(3, Duration::ZERO),
14425        );
14426        let runtime = supervisor.runtime_config();
14427        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
14428        let spec = ModuleSpec {
14429            module_id: module_id.to_string(),
14430            program: stub_path(),
14431            // Zero args deliberately: a `--subc` argument would make the stub dial
14432            // a daemon that is not there, and the failure would land in the same
14433            // stderr ring this fixture exists to keep quiet.
14434            args: Vec::new(),
14435            env: vec![
14436                ("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string()),
14437                (
14438                    "FAKE_AFT_GRANDCHILD_PID_FILE".to_string(),
14439                    pid_file.display().to_string(),
14440                ),
14441            ],
14442            reserved: false,
14443            reserved_prefixes: Vec::new(),
14444            protocol: ModuleProtocol::Subc,
14445            overlap: Default::default(),
14446        };
14447        let child = spawn_and_mark_running(&spec, &runtime, &snapshot)
14448            .expect("spawn the supervised fixture");
14449        let grandchild = read_grandchild_pid(&pid_file);
14450        Fixture {
14451            _dir: dir,
14452            module_id: module_id.to_string(),
14453            grandchild,
14454            child: Some(child),
14455            registry: Arc::new(Registry::default()),
14456            snapshot,
14457            terminal_ring: Arc::clone(&runtime.terminal_ring),
14458            spawn_events: SpawnEventFeed::default(),
14459        }
14460    }
14461
14462    impl Fixture {
14463        /// Drain through the supervisor's own teardown path.
14464        async fn drain(&mut self) {
14465            let child = self
14466                .child
14467                .take()
14468                .expect("the fixture child is still present");
14469            drain_child_to_state(
14470                &self.module_id,
14471                ModuleProtocol::Subc,
14472                // No forwarding table in this fixture, so nothing reaches the
14473                // child over a connection.
14474                StopNotice::NotSent,
14475                &self.registry,
14476                None,
14477                &self.snapshot,
14478                &self.terminal_ring,
14479                &self.spawn_events,
14480                child,
14481                Duration::from_millis(500),
14482                ModuleState::Stopped,
14483                Some(false),
14484            )
14485            .await
14486            .expect("drain the supervised fixture");
14487        }
14488    }
14489
14490    /// Teardown reaps the grandchild, not merely the direct child.
14491    ///
14492    /// This is the assertion the change exists for. Before containment the
14493    /// grandchild survived: it is a separate process, and `start_kill` is
14494    /// `TerminateProcess` scoped to one pid.
14495    #[tokio::test]
14496    async fn teardown_reaps_the_grandchild() {
14497        let mut fixture = fixture("teardown-grandchild", "tree-teardown");
14498        let grandchild = fixture.grandchild;
14499
14500        assert!(
14501            subc_jobobject::process_exists(grandchild),
14502            "grandchild {grandchild} must be alive before teardown, or this proves nothing"
14503        );
14504
14505        fixture.drain().await;
14506
14507        assert!(
14508            subc_jobobject::wait_for_process_exit(grandchild, Duration::from_secs(10)),
14509            "grandchild {grandchild} outlived module teardown: the tree was not contained"
14510        );
14511    }
14512
14513    /// The mutation control: with containment withheld, the grandchild survives
14514    /// the same kill.
14515    ///
14516    /// This is the defect reproduction from #109 — a direct-child kill reaches
14517    /// one pid, and the grandchild is a different process. It spawns OUTSIDE the
14518    /// supervisor because `spawn_and_mark_running` now always contains on
14519    /// Windows, which is the point: there is no longer a path that spawns
14520    /// uncontained, so the control has to construct one.
14521    ///
14522    /// Its job is to keep `teardown_reaps_the_grandchild` honest. If the
14523    /// grandchild ever dies here, that test is passing for a reason unrelated to
14524    /// the job object and the containment claim is unproven.
14525    #[test]
14526    fn an_uncontained_grandchild_survives_a_direct_child_kill() {
14527        let dir = ScratchDir::new("teardown-uncontained");
14528        let pid_file = dir.join("grandchild.pid");
14529        let mut child = std::process::Command::new(stub_path())
14530            .env("FAKE_AFT_NEVER_CONNECT", "1")
14531            .env(
14532                "FAKE_AFT_GRANDCHILD_PID_FILE",
14533                pid_file.display().to_string(),
14534            )
14535            .stdin(std::process::Stdio::null())
14536            .stdout(std::process::Stdio::null())
14537            .stderr(std::process::Stdio::null())
14538            .spawn()
14539            .expect("spawn the uncontained fixture");
14540        let grandchild = read_grandchild_pid(&pid_file);
14541
14542        // Exactly what the pre-fix teardown did: kill the direct child.
14543        child.kill().expect("kill the direct child");
14544        let _ = child.wait();
14545
14546        assert!(
14547            subc_jobobject::process_exists(grandchild),
14548            "grandchild {grandchild} died with the direct child, so this control no longer \
14549             distinguishes contained from uncontained teardown and the regression test is \
14550             passing vacuously"
14551        );
14552
14553        // The orphan this control demonstrates is the leak the fix prevents, so
14554        // the control must not leave one behind.
14555        kill_tree(grandchild);
14556    }
14557
14558    /// Crash durability: closing the containment handle reaps the tree with no
14559    /// teardown code running at all.
14560    ///
14561    /// This is the case `taskkill /T` cannot cover — a daemon that dies cannot
14562    /// call anything — and it is why containment is a kernel property of the
14563    /// handle rather than a step in the drain. Discovered by getting the
14564    /// mutation control wrong: clearing `job` to "disable" containment instead
14565    /// killed the tree, which is the guarantee, not a mistake.
14566    #[tokio::test]
14567    async fn dropping_containment_reaps_the_grandchild() {
14568        let mut fixture = fixture("drop-containment", "tree-drop");
14569        let grandchild = fixture.grandchild;
14570
14571        assert!(subc_jobobject::process_exists(grandchild));
14572
14573        // No `drain` call, no kill: dropping the handle is the entire mechanism.
14574        fixture.child.as_mut().expect("child present").job = None;
14575
14576        assert!(
14577            subc_jobobject::wait_for_process_exit(grandchild, Duration::from_secs(10)),
14578            "grandchild {grandchild} survived the containment handle closing, so a daemon \
14579             crash would leave the tree behind"
14580        );
14581    }
14582
14583    /// Kill a pid and its tree, then confirm it is gone.
14584    fn kill_tree(pid: u32) {
14585        let _ = std::process::Command::new("taskkill.exe")
14586            .args(["/PID", &pid.to_string(), "/T", "/F"])
14587            .stdin(std::process::Stdio::null())
14588            .stdout(std::process::Stdio::null())
14589            .stderr(std::process::Stdio::null())
14590            .status();
14591        assert!(
14592            subc_jobobject::wait_for_process_exit(pid, Duration::from_secs(10)),
14593            "could not clean up grandchild {pid}"
14594        );
14595    }
14596}
14597
14598#[cfg(test)]
14599mod privacy_trampoline_configuration_tests {
14600    #[tokio::test]
14601    #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
14602    async fn macos_spawn_without_a_configured_trampoline_refuses_by_name() {
14603        #[cfg(target_os = "macos")]
14604        {
14605            let supervisor = super::Supervisor::new(
14606                std::sync::Arc::new(crate::Registry::default()),
14607                super::RestartPolicy::default(),
14608            );
14609            let error = supervisor.spawn(spec()).unwrap_err();
14610            assert!(
14611                error
14612                    .to_string()
14613                    .contains("no privacy trampoline configured"),
14614                "{error}"
14615            );
14616        }
14617    }
14618
14619    #[tokio::test]
14620    #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
14621    async fn macos_wrong_trampoline_is_rejected_by_the_startup_probe() {
14622        #[cfg(target_os = "macos")]
14623        {
14624            let supervisor = super::Supervisor::new(
14625                std::sync::Arc::new(crate::Registry::default()),
14626                super::RestartPolicy::default(),
14627            )
14628            .with_privacy_trampoline(std::env::current_exe().unwrap());
14629            let error = supervisor.spawn(spec()).unwrap_err();
14630            assert!(
14631                error
14632                    .to_string()
14633                    .contains("binary does not implement the privacy trampoline protocol"),
14634                "{error}"
14635            );
14636        }
14637    }
14638
14639    #[cfg(target_os = "macos")]
14640    fn spec() -> super::ModuleSpec {
14641        super::ModuleSpec {
14642            module_id: "privacy-configuration".into(),
14643            program: "/bin/sleep".into(),
14644            args: vec!["30".into()],
14645            env: vec![],
14646            reserved: false,
14647            reserved_prefixes: vec![],
14648            protocol: subc_control::ModuleProtocol::None,
14649            overlap: super::ModuleOverlap::Exclusive,
14650        }
14651    }
14652}
14653
14654#[cfg(test)]
14655mod privacy_exec_boundary_tests {
14656    #[cfg(target_os = "macos")]
14657    use super::*;
14658    #[cfg(target_os = "macos")]
14659    use std::{
14660        io::{Read, Write},
14661        net::{TcpListener, TcpStream},
14662    };
14663
14664    /// Unit-test-only pause at the actual early image read, not at a later
14665    /// status read. Production supervisors never inspect this environment key.
14666    #[cfg(target_os = "macos")]
14667    pub(super) fn before_image_sample(spec: &ModuleSpec, pid: u32) {
14668        if let Some((_, path)) = spec
14669            .env
14670            .iter()
14671            .find(|(key, _)| key == "SUBC_TEST_PRIVACY_SAMPLE_BARRIER")
14672        {
14673            let mut barrier = TcpStream::connect(path).unwrap();
14674            barrier
14675                .set_read_timeout(Some(Duration::from_secs(30)))
14676                .unwrap();
14677            barrier.write_all(&pid.to_ne_bytes()).unwrap();
14678            let mut release = [0];
14679            barrier.read_exact(&mut release).unwrap();
14680            assert_eq!(&release, b"X");
14681        }
14682    }
14683
14684    #[cfg(target_os = "macos")]
14685    fn spec(program: &str, args: &[&str]) -> ModuleSpec {
14686        ModuleSpec {
14687            module_id: "privacy-boundary".into(),
14688            program: program.into(),
14689            args: args.iter().map(|arg| (*arg).into()).collect(),
14690            env: vec![],
14691            reserved: false,
14692            reserved_prefixes: vec![],
14693            protocol: ModuleProtocol::None,
14694            overlap: ModuleOverlap::Exclusive,
14695        }
14696    }
14697
14698    #[cfg(target_os = "macos")]
14699    async fn accept(listener: TcpListener) -> TcpStream {
14700        // Socket readiness, not elapsed time, establishes both pause points.
14701        let listener = tokio::net::TcpListener::from_std({
14702            listener.set_nonblocking(true).unwrap();
14703            listener
14704        })
14705        .unwrap();
14706        let (stream, _) = tokio::time::timeout(Duration::from_secs(30), listener.accept())
14707            .await
14708            .unwrap()
14709            .unwrap();
14710        let stream = stream.into_std().unwrap();
14711        stream.set_nonblocking(false).unwrap();
14712        stream
14713            .set_read_timeout(Some(Duration::from_secs(30)))
14714            .unwrap();
14715        stream
14716    }
14717
14718    #[tokio::test]
14719    #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
14720    async fn macos_roster_withholds_a_nonnull_trampoline_image_until_exec_confirmation() {
14721        #[cfg(target_os = "macos")]
14722        {
14723            let root = cortexkit_test_support::ScratchDir::new("privacy-roster-barrier");
14724            // Loopback sockets also work when the replay adapter's TMPDIR is
14725            // longer than Darwin's Unix-domain socket path limit.
14726            let exec_listener = TcpListener::bind("127.0.0.1:0").unwrap();
14727            let sample_listener = TcpListener::bind("127.0.0.1:0").unwrap();
14728            let record = root.join("live-children.json");
14729            let supervisor =
14730                Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
14731                    .with_live_children_record(&record);
14732            let runtime = supervisor.runtime_config();
14733            let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
14734            let mut spec = spec("/bin/sleep", &["30"]);
14735            spec.env = vec![
14736                (
14737                    "SUBC_TEST_PRIVACY_EXEC_BARRIER".into(),
14738                    exec_listener.local_addr().unwrap().to_string(),
14739                ),
14740                (
14741                    "SUBC_TEST_PRIVACY_SAMPLE_BARRIER".into(),
14742                    sample_listener.local_addr().unwrap().to_string(),
14743                ),
14744            ];
14745            let spawn = tokio::task::spawn_blocking(move || {
14746                spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap()
14747            });
14748            let mut sample = accept(sample_listener).await;
14749            let mut pid = [0; 4];
14750            sample.read_exact(&mut pid).unwrap();
14751            let pid = u32::from_ne_bytes(pid);
14752            let mut exec = accept(exec_listener).await;
14753            let mut ready = [0];
14754            exec.read_exact(&mut ready).unwrap();
14755            assert_eq!(&ready, b"R");
14756            // The early read is guaranteed to see a real, non-null trampoline
14757            // image: the fixture has reached its barrier and cannot exec yet.
14758            let trampoline = subc_os::file_identity(&test_privacy_trampoline()).unwrap();
14759            assert_eq!(
14760                observe_spawned_image(pid).unwrap().executable,
14761                Some(trampoline)
14762            );
14763            sample.write_all(b"X").unwrap();
14764            let mut child = spawn.await.unwrap();
14765            let early = crate::live_children::read_record(&record).unwrap();
14766            assert_eq!(early.len(), 1);
14767            assert_eq!(early[0].pid, pid);
14768            assert_eq!(
14769                early[0].executable, None,
14770                "unconfirmed trampoline image entered the roster"
14771            );
14772            assert!(child.report_ready.get().is_none());
14773            // The barrier's duration is unrelated to the production five-second
14774            // exec budget. Start the test's confirmation budget upon release.
14775            child.privacy_exec.as_mut().unwrap().deadline =
14776                tokio::time::Instant::now() + Duration::from_secs(30);
14777            exec.write_all(b"X").unwrap();
14778            child.confirm_privacy_exec().await;
14779            assert_eq!(child.spawn_failure, None);
14780            assert!(child.report_ready.get().is_some());
14781            let confirmed = crate::live_children::read_record(&record).unwrap();
14782            let module = subc_os::file_identity(std::path::Path::new("/bin/sleep")).unwrap();
14783            assert_ne!(module, trampoline);
14784            assert_eq!(confirmed[0].executable, Some(module.into()));
14785            child.start_kill().unwrap();
14786            child.wait().await.unwrap();
14787            child.release_roster();
14788        }
14789    }
14790
14791    #[tokio::test]
14792    #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
14793    async fn macos_already_exited_121_in_try_wait_is_a_module_exit_not_a_trampoline_refusal() {
14794        #[cfg(target_os = "macos")]
14795        {
14796            let registry = Arc::new(Registry::default());
14797            let policy = RestartPolicy::new(0, Duration::ZERO);
14798            let supervisor = Supervisor::new_for_test(Arc::clone(&registry), policy);
14799            let runtime = supervisor.runtime_config();
14800            let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
14801            let spec = spec("/bin/sh", &["-c", "exit 121"]);
14802            // Drive spawn and confirmation separately instead of starting the
14803            // monitor. WNOWAIT observes a real exit without consuming its status,
14804            // so confirmation's first try_wait must take the already-exited arm.
14805            let mut child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
14806            let pid = child.pid;
14807            tokio::task::spawn_blocking(move || {
14808                subc_os::fork_exec_test::wait_for_child_exit_without_reaping(pid)
14809            })
14810            .await
14811            .unwrap()
14812            .unwrap();
14813            child.privacy_exec.as_mut().unwrap().deadline =
14814                tokio::time::Instant::now() + Duration::from_secs(30);
14815            let status = child.wait().await.unwrap();
14816            assert_eq!(status.code(), Some(121));
14817            assert!(child.privacy_exec.is_none());
14818            assert!(
14819                child.report_ready.get().is_none(),
14820                "an exited module must not publish a live pid"
14821            );
14822            let report = classify_reaped_child_exit(&snapshot, &child, &status);
14823            on_child_exit(
14824                &spec,
14825                policy,
14826                &registry,
14827                &snapshot,
14828                &runtime.terminal_ring,
14829                &runtime.spawn_events,
14830                &runtime.child_roster,
14831                report,
14832            )
14833            .await;
14834            let state = lock_snapshot(&snapshot).unwrap();
14835            assert_eq!(state.state, ModuleState::Failed);
14836            assert_eq!(state.last_exit.as_ref().unwrap().code, Some(121));
14837            assert_eq!(state.reported_pid(), None);
14838            drop(state);
14839            let history = runtime.terminal_ring.lock().unwrap().snapshot();
14840            assert_eq!(history.entries.len(), 1);
14841            let terminal = &history.entries[0];
14842            assert_eq!(terminal.exit_code, Some(121));
14843            assert_eq!(terminal.exit_kind, subc_control::TerminalExitKind::Crash);
14844            assert_eq!(terminal.disposition, TerminalDisposition::Failed);
14845            assert_eq!(
14846                terminal.disposition_detail.as_deref(),
14847                Some(policy.budget_exhausted_detail().as_str()),
14848                "module exit 121 was classified as a trampoline refusal: {terminal:?}"
14849            );
14850            assert_eq!(child.spawn_failure, None);
14851            child.release_roster();
14852        }
14853    }
14854}
14855
14856/// The daemon's real spawn path hands a subc-wire child its launch nonce on
14857/// descriptor 3, without an environment copy. The shell records the nonce
14858/// and its environment after exec so these tests observe the real handover.
14859#[cfg(all(test, unix))]
14860mod launch_nonce_descriptor_tests {
14861    use super::{ChildRoster, ModuleProtocol, ModuleSpec, SupervisorHandle};
14862    use crate::stderr_tail::{StderrRing, StderrTailConfig};
14863    use cortexkit_test_support::ScratchDir;
14864    use std::{
14865        path::PathBuf,
14866        sync::{Arc, Mutex},
14867        time::{Duration, Instant},
14868    };
14869
14870    async fn probe(role: super::SpawnRole) {
14871        let scratch = ScratchDir::new("launch-nonce-descriptor");
14872        let fd_copy = scratch.join("from-descriptor");
14873        let env_copy = scratch.join("environment");
14874        let script = format!(
14875            "env > '{env}.tmp' && mv '{env}.tmp' '{env}'; cat <&3 > '{fd}.tmp' && mv '{fd}.tmp' '{fd}'; sleep 30",
14876            fd = fd_copy.display(), env = env_copy.display(),
14877        );
14878        let xdg = |name: &str| (name.to_string(), scratch.join(name).display().to_string());
14879        let spec = ModuleSpec {
14880            module_id: "nonce-descriptor-probe".to_string(),
14881            program: PathBuf::from("/bin/sh"),
14882            args: vec!["-c".to_string(), script],
14883            env: vec![
14884                xdg("XDG_DATA_HOME"),
14885                xdg("XDG_RUNTIME_DIR"),
14886                xdg("XDG_CONFIG_HOME"),
14887                ("SUBC_LAUNCH_NONCE".to_string(), "must-not-leak".to_string()),
14888            ],
14889            reserved: true,
14890            reserved_prefixes: Vec::new(),
14891            protocol: ModuleProtocol::Subc,
14892            overlap: Default::default(),
14893        };
14894        let handle = SupervisorHandle::new();
14895        let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
14896        let roster = ChildRoster::default();
14897        #[cfg(target_os = "macos")]
14898        {
14899            let path = super::test_privacy_trampoline();
14900            roster.set_privacy_trampoline(super::probe_privacy_trampoline(&path).map(|()| path));
14901        }
14902        let child = super::spawn_child_in_slot(
14903            &spec,
14904            None,
14905            Some(&handle),
14906            &ring,
14907            None,
14908            &roster,
14909            #[cfg(target_os = "linux")]
14910            None,
14911            role,
14912            matches!(role, super::SpawnRole::SwapCandidate),
14913        )
14914        .expect("spawn probe");
14915        let deadline = Instant::now() + Duration::from_secs(10);
14916        while !(fd_copy.exists() && env_copy.exists()) {
14917            assert!(Instant::now() < deadline, "probe never wrote its copies");
14918            tokio::time::sleep(Duration::from_millis(20)).await;
14919        }
14920        let nonce = std::fs::read_to_string(fd_copy).unwrap();
14921        assert!(!nonce.is_empty());
14922        let environment = std::fs::read_to_string(env_copy).unwrap();
14923        assert!(environment
14924            .lines()
14925            .any(|line| line.starts_with("SUBC_LAUNCH_NONCE_FD=3:")));
14926        let copy = environment
14927            .lines()
14928            .find_map(|line| line.strip_prefix("SUBC_LAUNCH_NONCE="));
14929        assert_eq!(
14930            copy, None,
14931            "Unix children must never receive the environment nonce"
14932        );
14933        if matches!(role, super::SpawnRole::Plain) {
14934            assert_eq!(
14935                handle.spawn_nonce(&spec.module_id).as_deref(),
14936                Some(nonce.as_str())
14937            );
14938        }
14939        drop(child);
14940    }
14941
14942    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
14943    async fn a_spawned_module_receives_its_nonce_only_on_descriptor_3() {
14944        probe(super::SpawnRole::Plain).await;
14945    }
14946
14947    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
14948    async fn swap_candidate_receives_its_nonce_only_on_descriptor_3() {
14949        probe(super::SpawnRole::SwapCandidate).await;
14950    }
14951}
14952
14953#[cfg(all(test, target_os = "linux"))]
14954mod cgroup_containment_tests {
14955    use super::*;
14956    use cortexkit_test_support::ScratchDir;
14957
14958    fn running(pid: u32) -> bool {
14959        // An orphan can remain a zombie until the container init reaps it.
14960        std::fs::read_to_string(format!("/proc/{pid}/stat"))
14961            .ok()
14962            .and_then(|stat| {
14963                stat.rsplit_once(") ")
14964                    .map(|(_, rest)| rest.starts_with('Z'))
14965            })
14966            .is_some_and(|zombie| !zombie)
14967    }
14968
14969    #[tokio::test]
14970    async fn linux_teardown_reaps_the_grandchild() {
14971        teardown_tree("linux_teardown_reaps_the_grandchild", false).await;
14972    }
14973
14974    #[tokio::test]
14975    async fn linux_shutdown_straggler_reaps_the_grandchild() {
14976        teardown_tree("linux_shutdown_straggler_reaps_the_grandchild", true).await;
14977    }
14978
14979    async fn teardown_tree(test_name: &str, shutdown: bool) {
14980        let dir = ScratchDir::new(test_name);
14981        let root = PathBuf::from(format!(
14982            "/sys/fs/cgroup/subc-tree-test-{test_name}-{}-{}",
14983            std::process::id(),
14984            unix_ms_now()
14985        ));
14986        if let Err(error) = std::fs::create_dir(&root) {
14987            assert!(
14988                std::env::var_os("SUBC_REQUIRE_CGROUP_TEST").is_none(),
14989                "required cgroup test cannot execute: {error}"
14990            );
14991            eprintln!(
14992                "SKIP {test_name}: no writable delegated cgroup at {}: {error}",
14993                root.display()
14994            );
14995            return;
14996        }
14997        let placement = subc_cgroup::prepare_at(&root)
14998            .expect("prepare isolated kernel cgroup")
14999            .expect("isolated cgroup is delegated");
15000        let module_id = "tree-teardown";
15001        let module = placement
15002            .module_path(module_id)
15003            .expect("create isolated module cgroup");
15004        if !module.join("cgroup.kill").exists() {
15005            std::fs::remove_dir(&module).unwrap();
15006            std::fs::remove_dir(root.join("subc-modules")).unwrap();
15007            std::fs::remove_dir(&root).unwrap();
15008            assert!(
15009                std::env::var_os("SUBC_REQUIRE_CGROUP_TEST").is_none(),
15010                "required cgroup.kill interface unavailable"
15011            );
15012            eprintln!("SKIP {test_name}: cgroup.kill unavailable (kernel < 5.14)");
15013            return;
15014        }
15015        let supervisor = Supervisor::new_for_test(
15016            Arc::new(Registry::default()),
15017            RestartPolicy::new(3, Duration::ZERO),
15018        )
15019        .with_cgroup_placement(Some(placement));
15020        let mut runtime = supervisor.runtime_config();
15021        runtime.child_roster = runtime
15022            .child_roster
15023            .for_module(Arc::new(Mutex::new(Duration::from_millis(100))));
15024        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
15025        let pid_file = dir.join("grandchild.pid");
15026        let spec = ModuleSpec {
15027            module_id: module_id.to_string(),
15028            program: PathBuf::from("/bin/sh"),
15029            args: vec![
15030                "-c".into(),
15031                "trap '' TERM; sleep 600 & echo $! > \"$1\"; wait".into(),
15032                "fixture".into(),
15033                pid_file.display().to_string(),
15034            ],
15035            env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
15036                .into_iter()
15037                .map(|key| (key.to_string(), dir.display().to_string()))
15038                .collect(),
15039            reserved: false,
15040            reserved_prefixes: Vec::new(),
15041            protocol: ModuleProtocol::None,
15042            overlap: Default::default(),
15043        };
15044        let child =
15045            spawn_and_mark_running(&spec, &runtime, &snapshot).expect("spawn supervised tree");
15046        let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
15047        let grandchild: u32 = loop {
15048            if let Ok(contents) = std::fs::read_to_string(&pid_file) {
15049                if let Ok(pid) = contents.trim().parse() {
15050                    break pid;
15051                }
15052            }
15053            assert!(
15054                tokio::time::Instant::now() < deadline,
15055                "grandchild pid was not recorded"
15056            );
15057            tokio::time::sleep(Duration::from_millis(10)).await;
15058        };
15059        assert!(
15060            running(grandchild),
15061            "grandchild must be alive before teardown"
15062        );
15063        if shutdown {
15064            let mut child = child;
15065            crate::child_roster::end_children_for_daemon_shutdown(
15066                &runtime.child_roster,
15067                false,
15068                std::future::pending(),
15069            )
15070            .await;
15071            child.wait().await.expect("reap shutdown straggler");
15072        } else {
15073            drain_child_to_state(
15074                module_id,
15075                ModuleProtocol::None,
15076                StopNotice::NotSent,
15077                &Registry::default(),
15078                None,
15079                &snapshot,
15080                &runtime.terminal_ring,
15081                &SpawnEventFeed::default(),
15082                child,
15083                Duration::from_millis(100),
15084                ModuleState::Stopped,
15085                Some(false),
15086            )
15087            .await
15088            .expect("real supervisor teardown");
15089        }
15090        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
15091        while running(grandchild) && tokio::time::Instant::now() < deadline {
15092            tokio::time::sleep(Duration::from_millis(10)).await;
15093        }
15094        let survived = running(grandchild);
15095        // Kill a surviving grandchild so a failed test does not leave it behind.
15096        if survived {
15097            let pid = rustix::process::Pid::from_raw(grandchild as i32).unwrap();
15098            let _ = rustix::process::kill_process(pid, rustix::process::Signal::KILL);
15099            tokio::time::sleep(Duration::from_millis(100)).await;
15100        }
15101        if module.exists() {
15102            std::fs::remove_dir(&module).expect("remove empty module cgroup");
15103        }
15104        std::fs::remove_dir(root.join("subc-modules")).unwrap();
15105        std::fs::remove_dir(&root).unwrap();
15106        assert!(
15107            !survived,
15108            "grandchild {grandchild} outlived module teardown"
15109        );
15110        eprintln!("EXECUTED {test_name}: grandchild {grandchild} killed in isolated cgroup");
15111    }
15112}