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, 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/// Command-line flag used by supervised modules to find subc.
59///
60/// subc launches module-mode children as `<module> --subc <connection-file-path>`.
61/// The path points at the TCP+key connection file; it is not an ambient signal and
62/// is never inherited by standalone children.
63pub const SUBC_ARG: &str = "--subc";
64
65const DEFAULT_MAX_RESTARTS: u32 = 3;
66const DEFAULT_BACKOFF: Duration = Duration::from_millis(100);
67const DEFAULT_MAX_BACKOFF: Duration = Duration::from_secs(30);
68/// The span `DEFAULT_MAX_RESTARTS` is counted over. Ten minutes is long enough
69/// to contain a real crash loop (which respawns in seconds) and short enough
70/// that unrelated crashes hours apart never accumulate into a permanent stop.
71const DEFAULT_RESTART_WINDOW: Duration = Duration::from_secs(600);
72/// How long a drain waits for already-dispatched requests to finalize before
73/// the child is torn down. Sized for TOOL-SCALE work (bash, inspect, builds),
74/// not RPC-scale: the original 2s value silently cut nearly every real tool
75/// call at the fence, making the wait-for-finalize design decorative for the
76/// workloads it existed for. Quiescence short-circuits, so an idle module
77/// restarts immediately regardless of this value; the budget is spent only
78/// when a genuine in-flight request is worth finishing. Per-module override:
79/// `drain_timeout_ms` in subc.jsonc; per-restart override: the operator's
80/// `supervisor.restart{drain_timeout_ms}` (0 = cut now, for wedge bounces
81/// where a stuck request will never settle).
82pub const DEFAULT_DRAIN_TIMEOUT: Duration = Duration::from_secs(30);
83const REGISTRY_RELEASE_TIMEOUT: Duration = Duration::from_secs(1);
84const REGISTRY_RELEASE_POLL: Duration = Duration::from_millis(10);
85/// How long a restart waits for the exited process's output readers.
86///
87/// The restart does not depend on either output reader finishing. A stderr
88/// reader still running at this bound is left running, and its later output goes
89/// into the exited process's own section of the stderr ring (see
90/// `StderrRing::push_line_from`), ending naturally at EOF on its pipe. So the
91/// bound no longer decides whether a crash's last lines are kept: under load
92/// the reader may simply not have been scheduled yet, and cutting it there
93/// discarded exactly the lines that explained the crash.
94///
95/// What the bound still decides is when the tail starts reporting
96/// `Incomplete`: a pipe open past it usually means a descendant of the exited
97/// process still holds it, and the tail cannot claim to be whole until that
98/// pipe closes. That is also the only case in which the wait costs the restart
99/// anything, because a pipe with no other holder reaches EOF when the process
100/// exits. Under load a slow reader can show `Incomplete` briefly; it returns to
101/// `Captured` at EOF with nothing lost.
102///
103/// The stdout reader carries no ring, only the capture file. It also continues
104/// to EOF after this bound: child exit closes the writer but does not mean the
105/// reader has consumed all buffered bytes. Late output from either pipe can
106/// therefore reach the shared capture file after the successor starts.
107const STDERR_PUMP_DRAIN_TIMEOUT: Duration = Duration::from_millis(250);
108/// Maximum number of supervised process spawn/exit facts retained per daemon incarnation.
109pub const SPAWN_EVENT_RING_CAPACITY: usize = 4096;
110const SPAWN_SUBSCRIBER_BUFFER: usize = SPAWN_EVENT_RING_CAPACITY + 1;
111/// Terminal Error code for a `supervisor.spawn_subscribe` stream the daemon
112/// dropped because the subscriber stopped draining its frames. The detail's
113/// `first_undelivered_cursor` names the first event it did not receive; the
114/// client resubscribes from the last cursor it did receive.
115pub(crate) const SPAWN_SUBSCRIBER_LAGGED_CODE: &str = "spawn_subscriber_lagged";
116
117#[cfg(target_os = "macos")]
118struct PrivacyExec {
119    reader: tokio::io::unix::AsyncFd<std::io::PipeReader>,
120    deadline: tokio::time::Instant,
121    expected: Option<subc_os::FileIdentity>,
122    trampoline: Option<subc_os::FileIdentity>,
123    script: bool,
124    module_id: String,
125}
126
127#[cfg(all(test, target_os = "macos"))]
128pub(crate) fn test_privacy_trampoline() -> PathBuf {
129    // Cargo's unit-test executable lives in <profile>/deps; its fixture bin
130    // lives beside that directory. This honors custom CARGO_TARGET_DIR too.
131    let path = std::env::current_exe()
132        .unwrap()
133        .parent()
134        .unwrap()
135        .parent()
136        .unwrap()
137        .join("privacy-trampoline-fixture");
138    // Without the fixture every macOS spawn is refused, and the tests that
139    // spawn fail later as a module in state Failed, which names the wrong
140    // cause. `cargo test -p subc-daemon --lib` alone does not build it.
141    assert!(
142        path.exists(),
143        "privacy-trampoline-fixture not built at {}: run `cargo build -p subc-daemon \
144         --bins --features test-support` or `cargo test -p subc-daemon` first",
145        path.display()
146    );
147    path
148}
149
150#[cfg(target_os = "macos")]
151fn probe_privacy_trampoline(path: &std::path::Path) -> Result<(), String> {
152    use std::io::Read;
153    let mut probe = std::process::Command::new(path)
154        .args(["__disclaim-exec", "--probe"])
155        .stdin(Stdio::null())
156        .stdout(Stdio::piped())
157        .stderr(Stdio::piped())
158        .spawn()
159        .map_err(|error| {
160            format!(
161                "privacy trampoline probe failed for {}: {error}",
162                path.display()
163            )
164        })?;
165    let deadline = std::time::Instant::now() + Duration::from_secs(5);
166    let status = loop {
167        match probe.try_wait() {
168            Ok(Some(status)) => break status,
169            Ok(None) if std::time::Instant::now() < deadline => {
170                std::thread::sleep(Duration::from_millis(5))
171            }
172            result => {
173                let _ = probe.kill();
174                let _ = probe.wait();
175                return Err(format!(
176                    "privacy trampoline probe failed or timed out for {}: {result:?}",
177                    path.display()
178                ));
179            }
180        }
181    };
182    let mut answer = String::new();
183    if let Some(stdout) = probe.stdout.take() {
184        let _ = stdout.take(256).read_to_string(&mut answer);
185    }
186    if status.success() && answer.trim() == subc_os::privacy_identity::TRAMPOLINE_PROBE {
187        return Ok(());
188    }
189    let mut diagnostic = String::new();
190    if let Some(stderr) = probe.stderr.take() {
191        let _ = stderr.take(1024).read_to_string(&mut diagnostic);
192    }
193    let cause = diagnostic
194        .trim()
195        .strip_prefix("ck-subc: own privacy identity refused: ")
196        .unwrap_or("binary does not implement the privacy trampoline protocol");
197    Err(format!(
198        "{cause}: probe of {} exited {status}",
199        path.display()
200    ))
201}
202
203#[cfg(target_os = "macos")]
204fn privacy_command(
205    spec: &ModuleSpec,
206    roster: &ChildRoster,
207) -> Result<
208    (
209        Command,
210        Option<PrivacyExec>,
211        subc_os::privacy_identity::ExecAcknowledgement,
212    ),
213    SuperviseError,
214> {
215    let failure = |cause: String| {
216        warn!(module_id = %spec.module_id, %cause, "privacy identity trampoline refused module spawn");
217        SuperviseError::Spawn {
218            program: spec.program.clone(),
219            source: io::Error::other(cause),
220            cgroup_path: None,
221        }
222    };
223    let trampoline = roster.privacy_trampoline().map_err(failure)?;
224    // Resolve PATH with the same environment the Command will receive. For
225    // scripts retain the existing orphan-identity rule: the kernel chooses
226    // the interpreter, and its observed image is the one recorded. Do not
227    // duplicate the kernel's shebang/PATH interpreter resolution in Rust.
228    let program = if spec.program.components().count() == 1 && !spec.program.is_absolute() {
229        let path = spec
230            .env
231            .iter()
232            .find(|(key, _)| key == "PATH")
233            .map(|(_, value)| std::ffi::OsString::from(value))
234            .or_else(|| std::env::var_os("PATH"))
235            .unwrap_or_else(|| "/usr/bin:/bin".into());
236        std::env::split_paths(&path)
237            .map(|dir| dir.join(&spec.program))
238            .find(|path| path.is_file())
239            .unwrap_or_else(|| spec.program.clone())
240    } else {
241        spec.program.clone()
242    };
243    let expected = subc_os::file_identity(&program);
244    let trampoline_image = subc_os::file_identity(&trampoline);
245    let script = {
246        use std::io::Read;
247        let mut prefix = [0u8; 2];
248        std::fs::File::open(&program)
249            .is_ok_and(|mut file| file.read_exact(&mut prefix).is_ok() && &prefix == b"#!")
250    };
251    if expected.is_none() || expected == subc_os::file_identity(&trampoline) {
252        return Err(failure(
253            "privacy identity module executable is missing or is the trampoline itself".to_string(),
254        ));
255    }
256    let (reader, ack) = subc_os::privacy_identity::ExecAcknowledgement::pipe()
257        .map_err(|error| failure(error.to_string()))?;
258    let reader =
259        tokio::io::unix::AsyncFd::new(reader).map_err(|error| failure(error.to_string()))?;
260    let mut command = Command::new(&trampoline);
261    command
262        .arg("__disclaim-exec")
263        .arg(ack.fd().to_string())
264        .arg(&program);
265    ack.install(command.as_std_mut());
266    Ok((
267        command,
268        Some(PrivacyExec {
269            reader,
270            deadline: tokio::time::Instant::now() + Duration::from_secs(5),
271            expected,
272            trampoline: trampoline_image,
273            script,
274            module_id: spec.module_id.clone(),
275        }),
276        ack,
277    ))
278}
279
280struct SupervisedChild {
281    child: Child,
282    #[cfg(windows)]
283    nonce_delivery: Option<subc_os::LaunchNoncePipeDelivery>,
284    #[cfg(target_os = "macos")]
285    privacy_exec: Option<PrivacyExec>,
286    /// Set once this launch's exec acknowledgement confirms the module image.
287    /// On macOS the pid first runs the `ck-subc` launch trampoline (see
288    /// `subc_os::privacy_identity`), which then replaces itself with the
289    /// module. The supervisor owns and can kill that pid from spawn, but
290    /// status readers report it only after this latch is set, so nothing
291    /// reports the trampoline's image as the module's.
292    #[cfg(target_os = "macos")]
293    report_ready: Arc<OnceLock<()>>,
294    /// Refusal before the module image was accepted, retained for terminal records.
295    spawn_failure: Option<String>,
296    /// The protocol this process was launched with. A reload can store a new
297    /// launch spec with a different protocol, but that takes effect only at the
298    /// next spawn, so this process keeps being handled by the protocol it
299    /// actually speaks.
300    protocol: ModuleProtocol,
301    /// This process's cgroup name: a bounded module/slot label followed by a
302    /// spawn suffix unique to this process (when cgroup placement is on). A
303    /// retired process in a slot may still be draining when a later one is
304    /// spawned into that slot, so the suffix keeps the later process out of
305    /// the retired one's cgroup, which is the domain a kill applies to.
306    #[cfg(target_os = "linux")]
307    module_id: String,
308    #[cfg(target_os = "linux")]
309    cgroup_placement: Option<subc_cgroup::Placement>,
310    /// The job that contains this child and every process it spawns (issue #109).
311    ///
312    /// Dropping this handle is what reaps a surviving tree when no supervisor
313    /// code runs — a daemon crash — because the job carries
314    /// `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`.
315    ///
316    /// That limit is not crash-only, and the difference is worth knowing: a
317    /// Windows daemon *stop* is `taskkill` or the scheduler's `/End` — the
318    /// SIGTERM handler is `#[cfg(unix)]` — so the daemon dies with no stop
319    /// notice and the kernel closes the job handle, `TerminateProcess`ing every
320    /// module at once. Before this change they survived that, saw EOF on the
321    /// control socket, and ran their own teardown; Unix keeps that path
322    /// deliberately, so a module can seal a WAL or close a capture rather than
323    /// be killed mid-write. So this trades graceful teardown on every Windows
324    /// daemon stop for containment on a crash, which is the right way round
325    /// today: orphaned GPU workers are a reported, recurring problem, and the
326    /// modules that write most heavily do not run on Windows.
327    ///
328    /// The fix is a real Windows stop path — the daemon draining before it
329    /// exits, the twin of the Unix SIGTERM handler. Once it exists, this limit
330    /// reaches only what the drain left behind, which is what it should reach.
331    #[cfg(windows)]
332    job: Option<subc_jobobject::JobObject>,
333    stdout_pump: Option<JoinHandle<()>>,
334    stderr_pump: Option<StderrPump>,
335    stderr_ring: Arc<Mutex<StderrRing>>,
336    spawned_at_ms: u64,
337    spawned_from: PathBuf,
338    spawned_file_identity: Option<SpawnedFileIdentity>,
339    process_start_time: Option<u64>,
340    process_identity: Option<ProcessIdentity>,
341    pid: u32,
342    /// This process's entry in the daemon's child roster, released when the
343    /// process is reaped or this handle is dropped.
344    roster_guard: Option<crate::child_roster::RosterGuard>,
345}
346
347impl SupervisedChild {
348    fn id(&self) -> Option<u32> {
349        Some(self.pid)
350    }
351
352    fn process_identity(&self) -> Option<ProcessIdentity> {
353        self.process_identity
354    }
355
356    async fn wait(&mut self) -> io::Result<ExitStatus> {
357        #[cfg(target_os = "macos")]
358        self.confirm_privacy_exec().await;
359        // The roster entry is NOT released here. A daemon shutdown waits for the
360        // roster to empty and then exits the process, so releasing at the reap
361        // let it exit before the exit handler wrote this child's terminal record
362        // (the stderr drain and snapshot update sit in between), and the
363        // shutdown's own `daemon_shutdown` record was intermittently lost. The
364        // caller releases it after recording the exit (`release_roster`), and
365        // dropping the handle releases it too.
366        let result = self.child.wait().await;
367        #[cfg(target_os = "linux")]
368        if result.is_ok() {
369            if let Some(placement) = self.cgroup_placement.as_ref() {
370                cleanup_reaped_cgroup(placement, &self.module_id).await;
371                // Keep ownership while awaiting kernel population changes: a
372                // drain timeout may cancel this wait and then escalate/reap.
373                self.cgroup_placement = None;
374            }
375        }
376        result
377    }
378
379    #[cfg(target_os = "macos")]
380    async fn confirm_privacy_exec(&mut self) {
381        let Some(pending) = &mut self.privacy_exec else {
382            return;
383        };
384        let result = tokio::time::timeout_at(pending.deadline, async {
385            let mut record = Vec::new();
386            loop {
387                let mut ready = pending.reader.readable().await?;
388                let read = ready.try_io(|reader| {
389                    use std::io::Read;
390                    let mut reader = reader.get_ref();
391                    let mut buffer = [0u8; 256];
392                    reader.read(&mut buffer).map(|count| (count, buffer))
393                });
394                match read {
395                    Ok(Ok((0, _))) => return Ok::<_, io::Error>(record),
396                    Ok(Ok((count, buffer))) => {
397                        if record.len() + count > 1024 {
398                            return Err(io::Error::other(
399                                "privacy exec refusal record is too long",
400                            ));
401                        }
402                        record.extend_from_slice(&buffer[..count]);
403                    }
404                    Ok(Err(error)) => return Err(error),
405                    Err(_) => continue,
406                }
407            }
408        })
409        .await;
410        // Keep the reader in self across await: select cancellation must not
411        // discard the handshake or reset its original five-second deadline.
412        let pending = self.privacy_exec.as_ref().expect("pending exec");
413        let cause = match result {
414            Err(_) => Some("privacy identity trampoline did not exec within 5s".to_string()),
415            Ok(Err(error)) => Some(format!(
416                "privacy identity exec acknowledgement failed: {error}"
417            )),
418            Ok(Ok(record)) if !record.is_empty() => Some(
419                std::str::from_utf8(&record)
420                    .ok()
421                    .and_then(|record| {
422                        record
423                            .trim()
424                            .strip_prefix(subc_os::privacy_identity::EXEC_REFUSAL_TAG)
425                    })
426                    .filter(|cause| !cause.is_empty())
427                    .unwrap_or("invalid privacy exec refusal record")
428                    .to_string(),
429            ),
430            Ok(Ok(_)) => match self.child.try_wait() {
431                // Empty EOF is the exec acknowledgement. A real module may exit
432                // immediately, including with a reserved trampoline status; no
433                // image is admitted, and its ordinary exit contract stays intact.
434                Ok(Some(_status)) => None,
435                Err(error) => Some(format!("privacy identity trampoline wait failed: {error}")),
436                Ok(None) => {
437                    let image = observe_spawned_image(self.pid);
438                    if let Some(image) = image.filter(|image| {
439                        image.executable.is_some()
440                            && image.executable != pending.trampoline
441                            && (image.executable == pending.expected || pending.script)
442                    }) {
443                        if let Some(guard) = &self.roster_guard {
444                            guard.confirm_executable(image);
445                        }
446                        let _ = self.report_ready.set(());
447                        info!(module_id = %pending.module_id, pid = self.pid,
448                            "module spawned with own privacy identity (responsibility disclaimed)");
449                        None
450                    } else if image.is_none()
451                        || image.is_some_and(|image| image.executable.is_none())
452                    {
453                        // A process can exit between try_wait and the kernel
454                        // image read. Empty EOF already acknowledged exec, so
455                        // preserve that module's ordinary exit rather than
456                        // mislabel a disappearing image as trampoline refusal.
457                        // Keep pending in self across await so cancellation does
458                        // not discard validation or reset its original deadline.
459                        match tokio::time::timeout_at(pending.deadline, self.child.wait()).await {
460                            Ok(Ok(_status)) => None,
461                            Ok(Err(error)) => Some(format!("privacy identity trampoline wait failed: {error}")),
462                            Err(_) => Some("privacy identity module executable remained unreadable until the 5s exec deadline".to_string()),
463                        }
464                    } else {
465                        Some("privacy identity trampoline executable mismatch: expected the module program, not ck-subc".to_string())
466                    }
467                }
468            },
469        };
470        let pending = self.privacy_exec.take().expect("pending exec");
471        if let Some(cause) = cause {
472            warn!(module_id = %pending.module_id, pid = self.pid, %cause, "privacy identity trampoline refused module spawn");
473            self.spawn_failure = Some(cause);
474            // No image is admitted on failure. Reach the entire fresh process
475            // group, including a module which spawned a helper before refusal.
476            if let Some(pid) = rustix::process::Pid::from_raw(self.pid as i32) {
477                let _ = rustix::process::kill_process_group(pid, rustix::process::Signal::KILL);
478            }
479            let _ = self.child.start_kill();
480        }
481    }
482
483    /// Releases this child's daemon-shutdown roster entry once its exit has
484    /// been recorded. The pid is already reaped and free for reuse, so the
485    /// entry must not outlive the record any longer than that.
486    fn release_roster(&mut self) {
487        self.roster_guard = None;
488    }
489
490    /// Kill the child and, where containment is available, its process tree.
491    ///
492    /// `Child::kill` is `TerminateProcess` scoped to one pid, so a module with a
493    /// helper process leaked the helper — the Synapse embedding module's CUDA
494    /// worker holds the GPU allocation, so the leak cost VRAM until the next
495    /// restart of something else. Terminating the job reaches grandchildren that
496    /// a tree walk cannot, including one whose parent has already exited and
497    /// been reparented away.
498    ///
499    /// Best-effort like `request_graceful_stop`: a job failure is logged and the
500    /// direct-child kill still decides the outcome, so containment can never
501    /// change whether a module is reported as stopped.
502    fn start_kill(&mut self) -> io::Result<()> {
503        #[cfg(windows)]
504        if let Some(job) = &self.job {
505            if let Err(error) = job.terminate() {
506                debug!(
507                    error = %error,
508                    "job termination failed; the direct-child kill still owns the outcome"
509                );
510            }
511        }
512        #[cfg(target_os = "linux")]
513        kill_module_cgroup(self.cgroup_placement.as_ref(), &self.module_id);
514        self.child.start_kill()
515    }
516
517    async fn drain_stderr(&mut self, module_id: &str) {
518        if let Some(pump) = self.stdout_pump.take() {
519            settle_stdout_pump(module_id, pump, STDERR_PUMP_DRAIN_TIMEOUT).await;
520        }
521
522        let Some(pump) = self.stderr_pump.take() else {
523            return;
524        };
525        settle_stderr_pump(
526            module_id,
527            &self.stderr_ring,
528            pump,
529            STDERR_PUMP_DRAIN_TIMEOUT,
530        )
531        .await;
532    }
533}
534
535/// Bound the restart's wait, not the lifetime of the exited child's stdout
536/// reader. Slow pipe reads or capture writes must not turn the bound into lost
537/// output; dropping the handle lets the reader finish at EOF, like stderr.
538async fn settle_stdout_pump(module_id: &str, mut pump: JoinHandle<()>, bound: Duration) {
539    match timeout(bound, &mut pump).await {
540        Ok(Ok(())) => {}
541        Ok(Err(error)) => {
542            warn!(module_id, error = %error, "stdout pump ended unexpectedly");
543        }
544        Err(_) => {
545            drop(pump);
546            warn!(
547                module_id,
548                waited = ?bound,
549                "stdout pipe of the exited process has not drained; its reader keeps running without delaying the restart"
550            );
551        }
552    }
553}
554
555/// The reader task for one process's stderr, with the ring generation its
556/// lines are attributed to.
557struct StderrPump {
558    task: JoinHandle<()>,
559    generation: u64,
560}
561
562/// Retire an exited process's stderr reader and wait up to `bound` for it to
563/// reach EOF. A reader still running at the bound is detached, not stopped: it
564/// keeps filling the exited process's section of the ring until its pipe
565/// closes, and the tail reads `Incomplete` until then. See
566/// [`STDERR_PUMP_DRAIN_TIMEOUT`] for why.
567async fn settle_stderr_pump(
568    module_id: &str,
569    ring: &Arc<Mutex<StderrRing>>,
570    pump: StderrPump,
571    bound: Duration,
572) {
573    let lock = || ring.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
574    let StderrPump {
575        mut task,
576        generation,
577    } = pump;
578    lock().retire_pump(generation);
579    match timeout(bound, &mut task).await {
580        Ok(Ok(())) => {}
581        Ok(Err(err)) => {
582            let mut ring = lock();
583            ring.mark_incomplete_from(generation, format!("stderr pump ended unexpectedly: {err}"));
584            ring.finish_pump(generation);
585            warn!(module_id, error = %err, "stderr pump ended before clean EOF");
586        }
587        Err(_) => {
588            // Dropping the handle detaches the task; it ends at EOF on its pipe.
589            drop(task);
590            lock().mark_pump_late(
591                generation,
592                format!(
593                    "stderr of the exited process had not reached EOF {bound:?} after it was \
594                     retired (a descendant may still hold the pipe open); lines it still \
595                     writes are kept in that process's section"
596                ),
597            );
598            warn!(
599                module_id,
600                waited = ?bound,
601                "stderr pipe of the exited process is still open; its reader keeps running without delaying the restart"
602            );
603        }
604    }
605}
606
607fn registration_release_events() -> &'static watch::Sender<u64> {
608    static EVENTS: OnceLock<watch::Sender<u64>> = OnceLock::new();
609    EVENTS.get_or_init(|| {
610        let (sender, _receiver) = watch::channel(0);
611        sender
612    })
613}
614
615pub(crate) fn notify_registration_release() {
616    let events = registration_release_events();
617    let next_generation = (*events.borrow()).wrapping_add(1);
618    events.send_replace(next_generation);
619}
620
621/// How to launch one singleton module process.
622#[derive(Debug, Clone, PartialEq, Eq)]
623pub struct ModuleSpec {
624    pub module_id: String,
625    pub program: PathBuf,
626    pub args: Vec<String>,
627    pub env: Vec<(String, String)>,
628    /// When true this is a reserved module: each spawn gets a fresh one-time launch
629    /// nonce that the child must echo in its HELLO, so only the daemon-spawned
630    /// process can register this module_id (a security-boundary module like the
631    /// credential vault must not be impersonable while it is down/restarting).
632    pub reserved: bool,
633    /// Module-id prefixes this supervised module owns for reserved HELLO checks.
634    /// Prefixes come from daemon config and must end in `:` before they reach the
635    /// supervisor; the owner module's current spawn nonce authorizes claims under
636    /// each prefix.
637    pub reserved_prefixes: Vec<String>,
638    /// The wire protocol this module speaks, as DECLARED in daemon config.
639    ///
640    /// [`ModuleProtocol::None`] changes five things and nothing else: wire health
641    /// probing is suppressed (an optional HTTP probe can replace it), teardown sends SIGTERM before waiting,
642    /// `route.open` is refused, the spawn passes NO `--subc <path>` argument
643    /// and NO launch nonce, and a clean exit the daemon did not request is
644    /// restarted as a crash rather than recorded as a stop (see `on_child_exit`:
645    /// a stock program exits 0 on a stray SIGTERM, and a stop would leave it
646    /// down for good). `SUBC_MODULE_ID` still goes into the environment,
647    /// because a process ignores an environment variable it does not read.
648    ///
649    /// The argument is the part that cannot be "harmless to a process that
650    /// ignores it": a stock binary exits on an unknown flag before it listens
651    /// (`nats-server`: "flag provided but not defined: -subc"), which is how the
652    /// first conformance run against this mode found it. The nonce is withheld
653    /// because a process that will never present it gains nothing from holding
654    /// it, and a secret in the environment of a process that does not need it is
655    /// a leak surface for no benefit.
656    pub protocol: ModuleProtocol,
657    /// Whether two processes of this module may run at once, which is what a
658    /// blue/green swap does for the length of its overlap. Declared in daemon
659    /// config because the daemon must be able to answer it while the module is
660    /// down, and so a module cannot talk itself into it after registering.
661    pub overlap: ModuleOverlap,
662}
663
664/// Whether a module tolerates a second process of itself running alongside.
665///
666/// Most modules are single-writer on their store (a WAL, a capture log, a
667/// resident index behind a writer barrier), and two processes on one store
668/// corrupt it. So a swap, which overlaps the old and new process by design,
669/// is refused unless the module's config opts in with `overlap: "safe"`.
670#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
671pub enum ModuleOverlap {
672    /// Never run two processes of this module at once. The default.
673    #[default]
674    Exclusive,
675    /// The module has said a second process of itself is harmless for the
676    /// length of a swap.
677    ///
678    /// Declare it only if a second instance can run for a few seconds without
679    /// touching ANY single-writer store: every database, WAL, index, projector
680    /// and scheduled job the module owns. A lease on part of that state is not
681    /// enough. broca's session lease guards WAL appends while its run index, its
682    /// store projector and its archive fold timer (which unlinks live WAL files)
683    /// stay single-writer, so broca is exclusive despite holding a lease. The
684    /// refusal only fires after this has been decided, so the decision is the
685    /// check.
686    Safe,
687}
688
689impl ModuleOverlap {
690    pub fn as_str(self) -> &'static str {
691        match self {
692            Self::Exclusive => "exclusive",
693            Self::Safe => "safe",
694        }
695    }
696}
697
698/// Environment variable telling a spawned module which case it was started
699/// for, before it sends HELLO. Only a swap candidate carries it, as
700/// [`SPAWN_ROLE_SWAP_CANDIDATE`]; every other spawn has it removed.
701///
702/// It chooses a warm-up budget, nothing else: a swap candidate can warm for
703/// longer because nobody waits on it, while a plain restart must flip ready
704/// quickly because callers see `module_warming` until it does. Absence means
705/// plain restart, the safe reading. The daemon trusts nothing about it; the
706/// candidate is proven by its launch nonce at HELLO.
707pub const SUBC_SPAWN_ROLE_ENV: &str = "SUBC_SPAWN_ROLE";
708/// The one value of [`SUBC_SPAWN_ROLE_ENV`] the daemon sets.
709pub const SPAWN_ROLE_SWAP_CANDIDATE: &str = "swap_candidate";
710/// How long a swap waits for its candidate to register and declare itself
711/// ready when the operator does not say. A module warming as a swap candidate
712/// may take up to 90 s (aft's ceiling, the largest in the fleet), so the
713/// daemon allows that plus time to start the process and send HELLO.
714pub const DEFAULT_SWAP_READY_TIMEOUT: Duration = Duration::from_secs(100);
715
716/// Bounded restart policy for crash exits.
717///
718/// `max_restarts` is the number of replacement processes allowed after the
719/// initial spawn WITHIN `window`. After that many crash restarts inside one
720/// window the module enters [`ModuleState::Failed`] and the supervisor stops
721/// the crash loop.
722///
723/// The budget is a RATE, not a lifetime total. It used to be a lifetime total,
724/// and that only survived because crashes were rare: a module that crashed
725/// three times across a week was disabled forever by crashes that had nothing
726/// to do with each other. That stopped being survivable once modules began
727/// exiting non-zero whenever the daemon's connection to them drops, because
728/// then every daemon-side connection drop spends a unit of the same budget and
729/// one flappy hour permanently stops a healthy module. Restarts older than
730/// `window` release their slot, so a module that crashed twice yesterday has a
731/// full budget today, while a genuine crash loop -- which is fast by
732/// definition -- still reaches the cap and stops.
733#[derive(Debug, Clone, Copy, PartialEq, Eq)]
734pub struct RestartPolicy {
735    pub max_restarts: u32,
736    /// Base delay before a crash replacement. The actual delay escalates with
737    /// the number of recent crash replacements and is capped by `max_backoff`.
738    pub backoff: Duration,
739    /// Maximum delay before a crash replacement.
740    pub max_backoff: Duration,
741    /// The span `max_restarts` is counted over. `Duration::ZERO` makes the
742    /// budget effectively infinite (nothing is ever in-window), which is why
743    /// daemon config refuses `window_secs: 0` rather than quietly accepting it.
744    pub window: Duration,
745}
746
747impl RestartPolicy {
748    /// A policy with the default crash window. Callers that care about the
749    /// window say so with [`Self::with_window`]; the ones that do not are
750    /// asking for the standard rate limit, not for no limit.
751    pub fn new(max_restarts: u32, backoff: Duration) -> Self {
752        Self {
753            max_restarts,
754            backoff,
755            max_backoff: DEFAULT_MAX_BACKOFF,
756            window: DEFAULT_RESTART_WINDOW,
757        }
758    }
759
760    pub fn with_max_backoff(mut self, max_backoff: Duration) -> Self {
761        self.max_backoff = max_backoff;
762        self
763    }
764
765    pub fn with_window(mut self, window: Duration) -> Self {
766        self.window = window;
767        self
768    }
769
770    /// Calculate the capped exponential delay for the next crash replacement.
771    /// `restart_in_window` is zero for the first replacement after an operator
772    /// action (restart, reload, re-enable) cleared the crash ring, or after all
773    /// older crash replacements have aged out of the window.
774    fn delay_for_restart(&self, restart_in_window: u32) -> Duration {
775        if self.backoff.is_zero() || self.max_backoff.is_zero() {
776            return Duration::ZERO;
777        }
778
779        let mut delay = self.backoff;
780        for _ in 0..restart_in_window {
781            if delay >= self.max_backoff {
782                return self.max_backoff;
783            }
784            delay = delay
785                .checked_mul(10)
786                .unwrap_or(self.max_backoff)
787                .min(self.max_backoff);
788        }
789        delay.min(self.max_backoff)
790    }
791
792    /// The one sentence that explains a budget-exhausted stop, used for both the
793    /// log line and the terminal record so the two cannot drift. It names the
794    /// window because `max_restarts=3` alone reads as a lifetime cap, which is
795    /// exactly what this budget is not.
796    fn budget_exhausted_detail(&self) -> String {
797        format!(
798            "crash budget exhausted: max_restarts={} within window_secs={}",
799            self.max_restarts,
800            self.window.as_secs()
801        )
802    }
803}
804
805impl Default for RestartPolicy {
806    fn default() -> Self {
807        Self {
808            max_restarts: DEFAULT_MAX_RESTARTS,
809            backoff: DEFAULT_BACKOFF,
810            max_backoff: DEFAULT_MAX_BACKOFF,
811            window: DEFAULT_RESTART_WINDOW,
812        }
813    }
814}
815
816#[derive(Debug, Clone, Copy, PartialEq, Eq)]
817struct CrashRestartSchedule {
818    restart_in_window: u32,
819    delay: Duration,
820}
821
822/// Whether the daemon itself will bring this module back after the exit being
823/// handled: it is enabled AND its in-window crash restarts are below the cap.
824///
825/// Takes `&mut` because reading the budget prunes it. Instants that fell out of
826/// the window are dropped here rather than by a timer, so the count is right
827/// the moment somebody asks and no bookkeeping runs for idle modules.
828fn daemon_will_restart(
829    state: &mut SupervisorSnapshot,
830    policy: &RestartPolicy,
831    now: Instant,
832) -> bool {
833    state.enabled && state.crash_restarts_in_window(policy.window, now) < policy.max_restarts
834}
835
836const DEFAULT_HEALTH_CADENCE: Duration = Duration::from_secs(30);
837const DEFAULT_HEALTH_DEADLINE: Duration = Duration::from_secs(5);
838const DEFAULT_HEALTH_FAILURE_THRESHOLD: u32 = 3;
839const MAX_HEALTH_METRICS_BYTES: usize = 16 * 1024;
840
841#[derive(Debug, Clone, Copy, PartialEq, Eq)]
842pub enum HealthAction {
843    Report,
844    Restart,
845    Alert,
846}
847
848impl fmt::Display for HealthAction {
849    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
850        f.write_str(match self {
851            Self::Report => "report",
852            Self::Restart => "restart",
853            Self::Alert => "alert",
854        })
855    }
856}
857
858#[derive(Debug, Clone, PartialEq, Eq)]
859pub struct HealthConfig {
860    /// Optional loopback HTTP endpoint for a managed non-wire process.
861    /// Changing it applies live on rescan; the process protocol changes only
862    /// at its next spawn.
863    pub http: Option<String>,
864    pub cadence: Duration,
865    pub deadline: Duration,
866    pub failure_threshold: u32,
867    pub on_degraded: HealthAction,
868    pub on_failing: HealthAction,
869    pub critical: bool,
870}
871
872impl Default for HealthConfig {
873    fn default() -> Self {
874        Self {
875            http: None,
876            cadence: DEFAULT_HEALTH_CADENCE,
877            deadline: DEFAULT_HEALTH_DEADLINE,
878            failure_threshold: DEFAULT_HEALTH_FAILURE_THRESHOLD,
879            on_degraded: HealthAction::Report,
880            on_failing: HealthAction::Report,
881            critical: false,
882        }
883    }
884}
885
886/// The supervisor's view of one module's health, relayed to clients over
887/// channel-0 and rendered by `ck health`.
888///
889/// THIS TYPE IS WHERE THE ABSENCE MEANINGS ARE CREATED, which is why they are
890/// stated here rather than only at the wire type a consumer reads. A reader can
891/// look up what `None` means; only a writer can silently change it, and the
892/// writer has no reason to go looking at a downstream contract before editing.
893///
894/// `last_probe_ms: None` MEANS NEVER PROBED, not probed-long-ago. It is cleared
895/// back to `None` on re-registration precisely so a respawned module does not
896/// carry its predecessor's timestamp — so an old value and an absent one call for
897/// opposite readings, and anything that defaulted this to a number would make a
898/// never-probed module indistinguishable from one probed at the epoch.
899///
900/// `detail` and `metrics` are `None` when the module published none on this
901/// probe, which does not mean it reported nothing wrong — it is also the shape
902/// when the probe never reached it. `last_probe_ms` is what separates those.
903#[derive(Debug, Clone, PartialEq)]
904pub struct ModuleHealthStatus {
905    pub status: SupervisorHealthStatus,
906    pub last_probe_ms: Option<u64>,
907    pub detail: Option<String>,
908    pub metrics: Option<Value>,
909    pub consecutive_failures: u32,
910    /// Number of replies received after a recurring health probe's deadline.
911    /// Unlike a timeout, every increment proves the module was alive.
912    pub late_answer_count: u64,
913    /// End-to-end latency of the newest late reply, measured from probe start.
914    pub last_late_answer_latency_ms: Option<u64>,
915    pub last_action: Option<String>,
916    /// Set together with `last_action`; the pair moves as one, and both being
917    /// absent means no escalation has ever been taken rather than that the last
918    /// one succeeded.
919    pub last_action_ms: Option<u64>,
920}
921
922impl Default for ModuleHealthStatus {
923    fn default() -> Self {
924        Self {
925            status: SupervisorHealthStatus::Unknown,
926            last_probe_ms: None,
927            detail: None,
928            metrics: None,
929            consecutive_failures: 0,
930            late_answer_count: 0,
931            last_late_answer_latency_ms: None,
932            last_action: None,
933            last_action_ms: None,
934        }
935    }
936}
937
938/// Typed lifecycle state for a supervised module.
939#[derive(Debug, Clone, Copy, PartialEq, Eq)]
940pub enum ModuleState {
941    Starting,
942    Running,
943    Unresponsive,
944    Restarting,
945    Draining,
946    Stopped,
947    Failed,
948    Disabled,
949}
950
951impl fmt::Display for ModuleState {
952    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
953        f.write_str(match self {
954            Self::Starting => "starting",
955            Self::Running => "running",
956            Self::Unresponsive => "unresponsive",
957            Self::Restarting => "restarting",
958            Self::Draining => "draining",
959            Self::Stopped => "stopped",
960            Self::Failed => "failed",
961            Self::Disabled => "disabled",
962        })
963    }
964}
965
966/// Supervisor classification of a child-process exit.
967#[derive(Debug, Clone, Copy, PartialEq, Eq)]
968pub enum ExitKind {
969    Clean,
970    Crash,
971    DeliberateSeverance,
972}
973
974impl From<ExitKind> for TerminalExitKind {
975    fn from(kind: ExitKind) -> Self {
976        match kind {
977            ExitKind::Clean => Self::Clean,
978            ExitKind::Crash => Self::Crash,
979            ExitKind::DeliberateSeverance => Self::DeliberateSeverance,
980        }
981    }
982}
983
984/// Exact process identity retained when a supervised module registers its
985/// connection. PID reuse makes a PID alone insufficient evidence of ownership.
986#[derive(Debug, Clone, Copy, PartialEq, Eq)]
987pub(crate) struct ProcessIdentity {
988    pub(crate) pid: u32,
989    pub(crate) start_time: u64,
990}
991
992/// Last observed child exit, if any.
993#[derive(Debug, Clone, PartialEq, Eq)]
994pub struct ExitReport {
995    pub kind: ExitKind,
996    pub code: Option<i32>,
997    pub signal: Option<i32>,
998    pub at_ms: u64,
999}
1000
1001/// Point-in-time module status answerable by subc without forwarding to the
1002/// module process.
1003#[derive(Debug, Clone, PartialEq)]
1004pub struct ModuleStatus {
1005    pub module_id: String,
1006    /// A previous daemon's hidden process, not a child owned by this supervisor.
1007    pub waiting_for_previous_pid: Option<u32>,
1008    /// The daemon's Windows per-spawn record: `pipe` after the process it
1009    /// spawned read all nonce bytes, otherwise `env` for the offered copy.
1010    /// List/status reports use this record, not the source the module sends
1011    /// in its registration message's manifest.provenance.launch_nonce_source.
1012    pub launch_nonce_source: Option<String>,
1013    pub state: ModuleState,
1014    pub enabled: bool,
1015    pub process_alive: bool,
1016    pub registration_active: bool,
1017    /// The module's declared wire protocol, carried beside `live` because it is
1018    /// what makes `live` readable: the two fields answer one question together.
1019    /// While a process is alive this is its launch declaration, not a later
1020    /// pending-reload edit. When down it is the configured next launch protocol.
1021    pub protocol: ModuleProtocol,
1022    /// Whether the module is serving, under the strongest definition the daemon
1023    /// can assert for its protocol.
1024    ///
1025    /// A subc module must also be REGISTERED: its process being alive says
1026    /// nothing about whether it can take a request. A `protocol: "none"` module
1027    /// never registers, so that term is dropped and this falls back to "enabled,
1028    /// running, and the process the daemon launched is alive" -- which is all
1029    /// the daemon observes about a process that speaks no subc wire. It stays a
1030    /// `bool` on the wire for compatibility; renderers pair it with `protocol`
1031    /// rather than printing it bare.
1032    pub live: bool,
1033    /// Crash restarts spent INSIDE `restart_window` as of this read. Older
1034    /// restarts have already released their slot, so this count can go down
1035    /// without anybody touching the module.
1036    pub restart_count: u32,
1037    /// Replacement processes spawned over this module's entire supervisor lifetime;
1038    /// unlike `restart_count`, this value is never reset by an operator action
1039    /// and never falls out of a window.
1040    pub lifetime_restarts: u32,
1041    pub spawn_generation: u64,
1042    /// The budget `restart_count` is spent against. Carried alongside the count
1043    /// because the count alone does not say how close the module is to being
1044    /// disabled, and reporting one without the other is what makes an
1045    /// about-to-be-retired module look ordinary.
1046    pub max_restarts: u32,
1047    /// The span `restart_count` is counted over. Carried with the pair above for
1048    /// the same reason they are carried together: "2 of 3" means one thing for a
1049    /// ten-minute window and something else entirely for a lifetime.
1050    pub restart_window: Duration,
1051    /// Effective drain and restart timing policy used by this running module.
1052    /// These values are carried together with the restart budget so status
1053    /// readers can compare configured intent with what the supervisor applied.
1054    pub drain_timeout: Duration,
1055    pub restart_backoff: Duration,
1056    pub restart_max_backoff: Duration,
1057    /// The module's process. On macOS this stays absent while the `ck-subc`
1058    /// launch trampoline is still running in that pid, and appears once the
1059    /// exec acknowledgement confirms the module image has replaced it. Launch
1060    /// time and the supervisor's own hold on the process are unaffected.
1061    pub pid: Option<u32>,
1062    pub spawned_at_ms: Option<u64>,
1063    pub spawned_from: Option<PathBuf>,
1064    pub process_start_time: Option<u64>,
1065    pub last_exit: Option<ExitReport>,
1066    pub health: ModuleHealthStatus,
1067}
1068
1069#[cfg(test)]
1070#[derive(Debug, Clone, Copy, PartialEq)]
1071struct ActorSelectCheckpoint {
1072    generation: u64,
1073    turn: u64,
1074    registered_connection: Option<ConnectionId>,
1075    next_probe_at: Option<Instant>,
1076    wake_after: Option<Duration>,
1077}
1078
1079#[derive(Debug, Clone, PartialEq)]
1080struct SupervisorSnapshot {
1081    #[cfg(windows)]
1082    pipe_consumed: Option<Arc<OnceLock<()>>>,
1083    /// Counts running-child loop turns, not executor polls of a parked wait.
1084    #[cfg(test)]
1085    actor_turns: u64,
1086    /// Running select after exec confirmation and with no unconsumed registry
1087    /// event. Recording a spawn or starting a loop turn is not this barrier.
1088    #[cfg(test)]
1089    actor_select: Option<ActorSelectCheckpoint>,
1090    state: ModuleState,
1091    enabled: bool,
1092    process_alive: bool,
1093    spawned_protocol: Option<ModuleProtocol>,
1094    spawn_failure: Option<String>,
1095    waiting_for_previous_pid: Option<u32>,
1096    /// When each crash restart was spent, oldest first. This IS the crash
1097    /// budget: its in-window length is the count an operator sees and the count
1098    /// the restart decision is made against, so there is no second counter that
1099    /// can disagree with it. Bounded by `max_restarts`, and cleared by the same
1100    /// operator actions that used to zero the old lifetime counter.
1101    crash_restarts: VecDeque<Instant>,
1102    lifetime_restarts: u32,
1103    /// Successful child spawns in this daemon incarnation.
1104    ///
1105    /// `lifetime_restarts` was considered and rejected: it starts at zero
1106    /// (line 640), successful initial/operator spawns in `set_running` do not
1107    /// increment it (lines 5264-5274), and crash/deliberate retry bookkeeping
1108    /// increments before a successful replacement exists (lines 604, 3846,
1109    /// and 3921), so a failed spawn can consume it. This counter moves only
1110    /// when a live PID is accepted below.
1111    spawn_generation: u64,
1112    pid: Option<u32>,
1113    #[cfg(target_os = "macos")]
1114    report_ready: Option<Arc<OnceLock<()>>>,
1115    /// Last reaped child, retained after current process facts are cleared.
1116    reaped_pid: Option<u32>,
1117    /// Whether the command-serving supervision loop has a scheduled respawn.
1118    respawn_pending: bool,
1119    /// A second restart is waiting for the replacement already scheduled.
1120    coalesced_restart_pending: bool,
1121    spawned_at_ms: Option<u64>,
1122    spawned_from: Option<PathBuf>,
1123    spawned_file_identity: Option<SpawnedFileIdentity>,
1124    process_start_time: Option<u64>,
1125    deliberate_severance: Option<ProcessIdentity>,
1126    last_exit: Option<ExitReport>,
1127    /// Diagnostic attached to the next drain's terminal record, if any.
1128    drain_disposition_detail: Option<String>,
1129    health: ModuleHealthStatus,
1130    /// Whether the current process was started as a swap candidate and so
1131    /// lives in the module's alternate cgroup. The next swap's candidate takes
1132    /// the other one, so the two processes of a swap never share a cgroup. A
1133    /// plain spawn always uses the primary cgroup.
1134    in_alternate_slot: bool,
1135    /// Whether the current `Draining` state ends in a replacement process
1136    /// (restart, reload, health restart) rather than a stop. Only meaningful
1137    /// while `state` is `Draining`; every entry into that state rewrites it.
1138    /// It is what lets route.open answer the retryable `module_reloading` to a
1139    /// consumer that reaches a still-registered process mid-restart, instead of
1140    /// the `supervisor_not_live` a stop or disable deserves.
1141    draining_to_replace: bool,
1142    /// Whether a configuration update has been applied since the current
1143    /// process was spawned, so that process runs an older spec than the one
1144    /// the supervisor now holds. A queued restart is only coalesced into a
1145    /// fresher process when this is false: a restart requested to pick up a
1146    /// new configuration must not be satisfied by a process that predates it.
1147    configuration_updated_since_spawn: bool,
1148}
1149
1150impl SupervisorSnapshot {
1151    /// The pid that status, provenance and resource readings may report. While
1152    /// the launch trampoline still runs in the pid, reading its executable or
1153    /// resource use would describe `ck-subc`, not the module, so none is
1154    /// reported until the exec acknowledgement confirms the module image.
1155    fn reported_pid(&self) -> Option<u32> {
1156        #[cfg(target_os = "macos")]
1157        if self
1158            .report_ready
1159            .as_ref()
1160            .is_some_and(|ready| ready.get().is_none())
1161        {
1162            return None;
1163        }
1164        self.pid
1165    }
1166
1167    fn starting() -> Self {
1168        Self::new(ModuleState::Starting, true)
1169    }
1170
1171    fn disabled() -> Self {
1172        Self::new(ModuleState::Disabled, false)
1173    }
1174
1175    fn failed() -> Self {
1176        Self::new(ModuleState::Failed, true)
1177    }
1178
1179    /// Crash restarts still inside `window`, having dropped the ones that are
1180    /// not. Pruning on read is what makes the budget a rate: an instant older
1181    /// than the window stops holding a slot the moment anybody counts.
1182    fn crash_restarts_in_window(&mut self, window: Duration, now: Instant) -> u32 {
1183        while let Some(oldest) = self.crash_restarts.front() {
1184            if now.duration_since(*oldest) > window {
1185                self.crash_restarts.pop_front();
1186            } else {
1187                break;
1188            }
1189        }
1190        u32::try_from(self.crash_restarts.len()).unwrap_or(u32::MAX)
1191    }
1192
1193    /// Spend one unit of the crash budget and record the restart in the ledger.
1194    ///
1195    /// The ring is bounded by the cap because more than `max_restarts` in-window
1196    /// instants can never be reached (the caller refuses the restart first), so
1197    /// anything beyond that is an unbounded queue waiting to happen.
1198    fn record_crash_restart(&mut self, policy: &RestartPolicy, now: Instant) {
1199        self.crash_restarts.push_back(now);
1200        while self.crash_restarts.len() > policy.max_restarts as usize {
1201            self.crash_restarts.pop_front();
1202        }
1203        self.lifetime_restarts += 1;
1204    }
1205
1206    /// Reserve one crash-restart slot and calculate the delay before respawning.
1207    /// The count is captured before recording this restart, so the first retry
1208    /// uses the base delay and each later in-window retry escalates once.
1209    fn next_crash_restart(
1210        &mut self,
1211        policy: &RestartPolicy,
1212        now: Instant,
1213    ) -> Option<CrashRestartSchedule> {
1214        let restart_in_window = self.crash_restarts_in_window(policy.window, now);
1215        if restart_in_window >= policy.max_restarts {
1216            return None;
1217        }
1218        self.record_crash_restart(policy, now);
1219        Some(CrashRestartSchedule {
1220            restart_in_window,
1221            delay: policy.delay_for_restart(restart_in_window),
1222        })
1223    }
1224
1225    /// Give the module its full budget back, as an operator restart, reload, or
1226    /// re-enable does. `lifetime_restarts` deliberately does not move: it is the
1227    /// ledger of what actually happened, and an operator action does not unmake
1228    /// the crashes.
1229    fn clear_crash_restarts(&mut self) {
1230        self.crash_restarts.clear();
1231    }
1232
1233    fn new(state: ModuleState, enabled: bool) -> Self {
1234        Self {
1235            #[cfg(test)]
1236            actor_turns: 0,
1237            #[cfg(test)]
1238            actor_select: None,
1239            state,
1240            enabled,
1241            process_alive: false,
1242            #[cfg(windows)]
1243            pipe_consumed: None,
1244            spawned_protocol: None,
1245            spawn_failure: None,
1246            waiting_for_previous_pid: None,
1247            crash_restarts: VecDeque::new(),
1248            lifetime_restarts: 0,
1249            spawn_generation: 0,
1250            pid: None,
1251            #[cfg(target_os = "macos")]
1252            report_ready: None,
1253            reaped_pid: None,
1254            respawn_pending: false,
1255            coalesced_restart_pending: false,
1256            spawned_at_ms: None,
1257            spawned_from: None,
1258            spawned_file_identity: None,
1259            process_start_time: None,
1260            deliberate_severance: None,
1261            last_exit: None,
1262            drain_disposition_detail: None,
1263            health: ModuleHealthStatus::default(),
1264            in_alternate_slot: false,
1265            draining_to_replace: false,
1266            configuration_updated_since_spawn: false,
1267        }
1268    }
1269}
1270
1271type SharedSnapshot = Arc<Mutex<SupervisorSnapshot>>;
1272
1273type SpawnSubscriberKey = (ConnectionId, u64);
1274
1275#[derive(Debug)]
1276struct SpawnSubscriber {
1277    version: u8,
1278    frames: mpsc::Sender<Frame>,
1279    /// Tells this subscriber's forwarder that it was dropped for lagging, and
1280    /// from which event. The full frame channel cannot carry that news, so it
1281    /// travels beside it; see `SpawnEventFeed::subscribe`.
1282    lagged: Option<oneshot::Sender<SpawnCursor>>,
1283}
1284
1285#[derive(Debug)]
1286struct SpawnEventState {
1287    daemon_incarnation: String,
1288    seq: u64,
1289    capacity: usize,
1290    live: HashMap<String, LiveSpawn>,
1291    generations: HashMap<String, u64>,
1292    events: VecDeque<SpawnEvent>,
1293    subscribers: HashMap<SpawnSubscriberKey, SpawnSubscriber>,
1294}
1295
1296impl Default for SpawnEventState {
1297    fn default() -> Self {
1298        Self {
1299            daemon_incarnation: "unconfigured".to_string(),
1300            seq: 0,
1301            capacity: SPAWN_EVENT_RING_CAPACITY,
1302            live: HashMap::new(),
1303            generations: HashMap::new(),
1304            events: VecDeque::new(),
1305            subscribers: HashMap::new(),
1306        }
1307    }
1308}
1309
1310#[derive(Debug, Clone, Default)]
1311struct SpawnEventFeed(
1312    Arc<Mutex<SpawnEventState>>,
1313    #[cfg(test)] Arc<Mutex<Option<std::net::TcpStream>>>,
1314);
1315
1316#[derive(Debug, Clone, PartialEq, Eq)]
1317pub(crate) enum SpawnSubscribeRefusal {
1318    ForeignIncarnation { current: String },
1319    TooOld { oldest: SpawnCursor },
1320    Frame(String),
1321}
1322
1323impl SpawnEventFeed {
1324    fn configure_incarnation(&self, daemon_incarnation: String) {
1325        let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1326        state.daemon_incarnation = daemon_incarnation;
1327        state.seq = 0;
1328        state.live.clear();
1329        state.generations.clear();
1330        state.events.clear();
1331        state.subscribers.clear();
1332    }
1333
1334    fn cursor(state: &SpawnEventState) -> SpawnCursor {
1335        SpawnCursor {
1336            daemon_incarnation: state.daemon_incarnation.clone(),
1337            seq: state.seq,
1338        }
1339    }
1340
1341    fn snapshot(&self) -> SpawnSnapshot {
1342        let state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1343        let mut live = state.live.values().cloned().collect::<Vec<_>>();
1344        live.sort_by(|left, right| left.module_id.cmp(&right.module_id));
1345        SpawnSnapshot {
1346            cursor: Self::cursor(&state),
1347            ring_bound: state.capacity as u64,
1348            live,
1349        }
1350    }
1351
1352    fn emit_spawned(&self, module_id: &str, pid: u32, spawned_at_ms: u64) -> u64 {
1353        let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1354        let generation = state
1355            .generations
1356            .get(module_id)
1357            .copied()
1358            .unwrap_or(0)
1359            .checked_add(1)
1360            .expect("spawn generation exhausted");
1361        state.generations.insert(module_id.to_string(), generation);
1362        let live = LiveSpawn {
1363            module_id: module_id.to_string(),
1364            spawn_generation: generation,
1365            pid,
1366            spawned_at_ms,
1367        };
1368        state.live.insert(module_id.to_string(), live);
1369        Self::emit_locked(
1370            &mut state,
1371            SpawnEventKind::Spawned,
1372            module_id.to_string(),
1373            generation,
1374            pid,
1375            None,
1376            None,
1377        );
1378        generation
1379    }
1380
1381    fn emit_exited(&self, module_id: &str, exit_code: Option<i32>, exit_signal: Option<i32>) {
1382        let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1383        let Some(live) = state.live.remove(module_id) else {
1384            warn!(
1385                module_id,
1386                "terminal record had no live spawn event identity"
1387            );
1388            return;
1389        };
1390        Self::emit_locked(
1391            &mut state,
1392            SpawnEventKind::Exited,
1393            module_id.to_string(),
1394            live.spawn_generation,
1395            live.pid,
1396            exit_code,
1397            exit_signal,
1398        );
1399    }
1400
1401    /// Report the exit of a process that a swap has already replaced.
1402    ///
1403    /// `emit_exited` removes the module's live entry, which after a swap's
1404    /// cutover describes the promoted candidate, not the old process now
1405    /// exiting. This emits the old generation's exit and leaves the live entry
1406    /// alone unless it still names that generation.
1407    fn emit_superseded_exited(
1408        &self,
1409        module_id: &str,
1410        spawn_generation: u64,
1411        pid: u32,
1412        exit_code: Option<i32>,
1413        exit_signal: Option<i32>,
1414    ) {
1415        let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1416        if state
1417            .live
1418            .get(module_id)
1419            .is_some_and(|live| live.spawn_generation == spawn_generation)
1420        {
1421            state.live.remove(module_id);
1422        }
1423        Self::emit_locked(
1424            &mut state,
1425            SpawnEventKind::Exited,
1426            module_id.to_string(),
1427            spawn_generation,
1428            pid,
1429            exit_code,
1430            exit_signal,
1431        );
1432    }
1433
1434    #[allow(clippy::too_many_arguments)]
1435    fn emit_locked(
1436        state: &mut SpawnEventState,
1437        kind: SpawnEventKind,
1438        module_id: String,
1439        spawn_generation: u64,
1440        pid: u32,
1441        exit_code: Option<i32>,
1442        exit_signal: Option<i32>,
1443    ) {
1444        state.seq = state
1445            .seq
1446            .checked_add(1)
1447            .expect("spawn event sequence exhausted");
1448        let event = SpawnEvent {
1449            cursor: Self::cursor(state),
1450            kind,
1451            module_id,
1452            spawn_generation,
1453            pid,
1454            exit_code,
1455            exit_signal,
1456        };
1457        state.events.push_back(event.clone());
1458        while state.events.len() > state.capacity {
1459            state.events.pop_front();
1460        }
1461        let body = match serde_json::to_vec(&event) {
1462            Ok(body) => body,
1463            Err(error) => {
1464                error!(%error, "failed to serialize supervisor spawn event");
1465                return;
1466            }
1467        };
1468        state.subscribers.retain(|(connection_id, corr), subscriber| {
1469            let frame = Frame::build_with_version(
1470                subscriber.version,
1471                FrameType::StreamData,
1472                control_flags(),
1473                0,
1474                0,
1475                *corr,
1476                body.clone(),
1477            );
1478            match frame {
1479                Ok(frame) => {
1480                    if subscriber.frames.try_send(frame).is_ok() {
1481                        true
1482                    } else {
1483                        warn!(connection_id = connection_id.get(), corr, "dropping lagged supervisor spawn subscriber");
1484                        if let Some(lagged) = subscriber.lagged.take() {
1485                            let _ = lagged.send(event.cursor.clone());
1486                        }
1487                        false
1488                    }
1489                }
1490                Err(error) => {
1491                    warn!(connection_id = connection_id.get(), corr, %error, "dropping supervisor spawn subscriber after frame build failure");
1492                    false
1493                }
1494            }
1495        });
1496    }
1497
1498    fn subscribe(
1499        &self,
1500        connection_id: ConnectionId,
1501        corr: u64,
1502        version: u8,
1503        since: Option<SpawnCursor>,
1504        sink: FrameSink,
1505    ) -> Result<(), SpawnSubscribeRefusal> {
1506        let (frames, mut receiver) = mpsc::channel(SPAWN_SUBSCRIBER_BUFFER);
1507        let (lagged, mut lagged_rx) = oneshot::channel::<SpawnCursor>();
1508        {
1509            let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1510            let replay = if let Some(since) = since {
1511                if since.daemon_incarnation != state.daemon_incarnation {
1512                    return Err(SpawnSubscribeRefusal::ForeignIncarnation {
1513                        current: state.daemon_incarnation.clone(),
1514                    });
1515                }
1516                if let Some(oldest) = state.events.front().map(|event| event.cursor.clone()) {
1517                    if since.seq < oldest.seq.saturating_sub(1) {
1518                        return Err(SpawnSubscribeRefusal::TooOld { oldest });
1519                    }
1520                }
1521                state
1522                    .events
1523                    .iter()
1524                    .filter(|event| event.cursor.seq > since.seq)
1525                    .cloned()
1526                    .collect::<Vec<_>>()
1527            } else {
1528                Vec::new()
1529            };
1530            for event in replay {
1531                let body = serde_json::to_vec(&event)
1532                    .map_err(|error| SpawnSubscribeRefusal::Frame(error.to_string()))?;
1533                let frame = Frame::build_with_version(
1534                    version,
1535                    FrameType::StreamData,
1536                    control_flags(),
1537                    0,
1538                    0,
1539                    corr,
1540                    body,
1541                )
1542                .map_err(|error| SpawnSubscribeRefusal::Frame(error.to_string()))?;
1543                frames
1544                    .try_send(frame)
1545                    .map_err(|error| SpawnSubscribeRefusal::Frame(error.to_string()))?;
1546            }
1547            state.subscribers.insert(
1548                (connection_id, corr),
1549                SpawnSubscriber {
1550                    version,
1551                    frames,
1552                    lagged: Some(lagged),
1553                },
1554            );
1555        }
1556        // The lagged terminal is sent here, by the forwarder, rather than by
1557        // the emitter: at the moment of the drop the subscriber's own channel
1558        // is full, and writing to the connection sink directly from the emitter
1559        // would put the Error AHEAD of the events still queued in that channel
1560        // (and the emitter holds the feed lock, so it cannot await the sink).
1561        // Dropping the subscriber drops the only sender, so `recv` drains every
1562        // queued event and then returns `None`; only then is the Error sent, so
1563        // the client sees each event it can keep, then the reason it was cut.
1564        // Cancel and connection removal drop the oneshot unsent, so they end
1565        // the stream with no Error.
1566        tokio::spawn(async move {
1567            while let Some(frame) = receiver.recv().await {
1568                if sink.send(frame).await.is_err() {
1569                    return;
1570                }
1571            }
1572            let Ok(first_undelivered) = lagged_rx.try_recv() else {
1573                return;
1574            };
1575            match spawn_subscriber_lagged_frame(version, corr, first_undelivered) {
1576                Ok(frame) => {
1577                    let _ = sink.send(frame).await;
1578                }
1579                Err(error) => {
1580                    error!(%error, corr, "failed to build lagged spawn subscriber terminal frame");
1581                }
1582            }
1583        });
1584        Ok(())
1585    }
1586
1587    fn cancel(&self, connection_id: ConnectionId, corr: u64) -> bool {
1588        let Some(subscriber) = self
1589            .0
1590            .lock()
1591            .unwrap_or_else(|p| p.into_inner())
1592            .subscribers
1593            .remove(&(connection_id, corr))
1594        else {
1595            return false;
1596        };
1597        if let Ok(frame) = Frame::build_with_version(
1598            subscriber.version,
1599            FrameType::StreamEnd,
1600            control_flags(),
1601            0,
1602            0,
1603            corr,
1604            Vec::new(),
1605        ) {
1606            tokio::spawn(async move {
1607                let _ = subscriber.frames.send(frame).await;
1608            });
1609        }
1610        true
1611    }
1612
1613    fn remove_connection(&self, connection_id: ConnectionId) {
1614        self.0
1615            .lock()
1616            .unwrap_or_else(|p| p.into_inner())
1617            .subscribers
1618            .retain(|(subscriber_connection, _), _| *subscriber_connection != connection_id);
1619    }
1620
1621    #[cfg(any(test, feature = "test-support"))]
1622    fn set_capacity(&self, capacity: usize) {
1623        self.0.lock().unwrap_or_else(|p| p.into_inner()).capacity = capacity;
1624    }
1625
1626    #[cfg(any(test, feature = "test-support"))]
1627    fn subscriber_count(&self) -> usize {
1628        self.0
1629            .lock()
1630            .unwrap_or_else(|p| p.into_inner())
1631            .subscribers
1632            .len()
1633    }
1634}
1635
1636/// Narrow process-liveness signal published by supervisors and consumed by passive liveness polls.
1637/// The terminal Error a lagged spawn subscriber receives after its queued events.
1638fn spawn_subscriber_lagged_frame(
1639    version: u8,
1640    corr: u64,
1641    first_undelivered: SpawnCursor,
1642) -> Result<Frame, String> {
1643    let body = serde_json::to_vec(&subc_protocol::ErrorBody {
1644        code: SPAWN_SUBSCRIBER_LAGGED_CODE.to_string(),
1645        message: "spawn subscriber fell behind and was dropped; resubscribe from the last cursor received"
1646            .to_string(),
1647        detail: Some(serde_json::json!({
1648            "first_undelivered_cursor": first_undelivered
1649        })),
1650    })
1651    .map_err(|error| error.to_string())?;
1652    Frame::build_with_version(version, FrameType::Error, control_flags(), 0, 0, corr, body)
1653        .map_err(|error| error.to_string())
1654}
1655
1656pub trait ModuleProcessLiveness: Send + Sync {
1657    fn process_live(&self, module_id: &str) -> Option<bool>;
1658
1659    /// Whether the supervisor is replacing this module's process right now: an
1660    /// operator restart or reload, a health restart, or a crash respawn whose
1661    /// backoff is running. A module in that state is not live, but a consumer
1662    /// refused now should retry shortly rather than treat the target as gone.
1663    /// Stopped, failed, and disabled modules are not replacing.
1664    fn process_replacing(&self, _module_id: &str) -> bool {
1665        false
1666    }
1667}
1668
1669/// Shared process-liveness registry keyed by supervised `module_id`.
1670#[derive(Debug, Clone, Default)]
1671pub struct SupervisorProcessLiveness {
1672    snapshots: Arc<Mutex<HashMap<String, SharedSnapshot>>>,
1673}
1674
1675impl SupervisorProcessLiveness {
1676    pub fn new() -> Self {
1677        Self::default()
1678    }
1679
1680    fn track(&self, module_id: String, snapshot: SharedSnapshot) {
1681        let mut snapshots = self
1682            .snapshots
1683            .lock()
1684            .unwrap_or_else(|poisoned| poisoned.into_inner());
1685        snapshots.insert(module_id, snapshot);
1686    }
1687
1688    fn untrack_if_current(&self, module_id: &str, snapshot: &SharedSnapshot) {
1689        let mut snapshots = self
1690            .snapshots
1691            .lock()
1692            .unwrap_or_else(|poisoned| poisoned.into_inner());
1693        let is_current = snapshots
1694            .get(module_id)
1695            .map(|tracked| Arc::ptr_eq(tracked, snapshot))
1696            .unwrap_or(false);
1697        if is_current {
1698            snapshots.remove(module_id);
1699        }
1700    }
1701}
1702
1703impl ModuleProcessLiveness for SupervisorProcessLiveness {
1704    fn process_live(&self, module_id: &str) -> Option<bool> {
1705        let snapshot = {
1706            let snapshots = self
1707                .snapshots
1708                .lock()
1709                .unwrap_or_else(|poisoned| poisoned.into_inner());
1710            snapshots.get(module_id).cloned()
1711        }?;
1712        let snapshot = snapshot
1713            .lock()
1714            .unwrap_or_else(|poisoned| poisoned.into_inner());
1715        Some(snapshot.state == ModuleState::Running && snapshot.process_alive)
1716    }
1717
1718    fn process_replacing(&self, module_id: &str) -> bool {
1719        let Some(snapshot) = self
1720            .snapshots
1721            .lock()
1722            .unwrap_or_else(|poisoned| poisoned.into_inner())
1723            .get(module_id)
1724            .cloned()
1725        else {
1726            return false;
1727        };
1728        let snapshot = snapshot
1729            .lock()
1730            .unwrap_or_else(|poisoned| poisoned.into_inner());
1731        snapshot.enabled
1732            && match snapshot.state {
1733                ModuleState::Restarting => true,
1734                ModuleState::Draining => snapshot.draining_to_replace,
1735                ModuleState::Starting
1736                | ModuleState::Running
1737                | ModuleState::Unresponsive
1738                | ModuleState::Stopped
1739                | ModuleState::Failed
1740                | ModuleState::Disabled => false,
1741            }
1742    }
1743}
1744
1745#[cfg(test)]
1746#[derive(Debug, Default)]
1747struct ReloadExitRecordGate {
1748    reached: tokio::sync::Notify,
1749    resume: tokio::sync::Notify,
1750}
1751
1752#[derive(Debug, Clone, Copy)]
1753enum RespawnKind {
1754    Spawn,
1755    Reload,
1756}
1757
1758#[derive(Debug, Clone, Copy)]
1759struct PendingRespawn {
1760    deadline: Instant,
1761    kind: RespawnKind,
1762}
1763
1764type ReloadReply = oneshot::Sender<Result<(), SuperviseError>>;
1765
1766#[derive(Debug, Clone)]
1767struct SupervisorRuntimeConfig {
1768    /// Restart operations hand their backoff to the loop, which keeps serving commands.
1769    scheduled_respawn: Arc<Mutex<Option<PendingRespawn>>>,
1770    /// A reload acknowledges completion only after its replacement registers.
1771    deferred_reload_reply: Arc<Mutex<Option<ReloadReply>>>,
1772    restart_policy: RestartPolicy,
1773    /// This module's RESOLVED drain budget: per-module config when present,
1774    /// else `default_drain_timeout`.
1775    drain_timeout: Duration,
1776    /// Shared with the status handle so the attested value changes atomically
1777    /// when a rescan updates the running drain policy.
1778    effective_drain_timeout: Arc<Mutex<Duration>>,
1779    /// The supervisor-wide fallback, kept so a configuration update that
1780    /// REMOVES the per-module override can re-resolve to it.
1781    default_drain_timeout: Duration,
1782    health: HealthConfig,
1783    connection_file_path: Option<PathBuf>,
1784    capture_logs_dir: Option<PathBuf>,
1785    forwarding: Option<Arc<ForwardingTable>>,
1786    /// The shared handle, so every spawn path (initial, restart, reload) records the
1787    /// reserved-module launch nonce the HELLO verifier checks against.
1788    supervisor_handle: Option<SupervisorHandle>,
1789    /// This module's stderr tail, shared with the [`SupervisedModule`] that answers
1790    /// status queries.
1791    ///
1792    /// One ring per module, held across every respawn. The lines explaining an exit
1793    /// are written BEFORE that exit, so a ring recreated per process would be empty
1794    /// exactly when it is asked for.
1795    stderr_ring: Arc<Mutex<StderrRing>>,
1796    terminal_ring: Arc<Mutex<TerminalRing>>,
1797    spawn_events: SpawnEventFeed,
1798    child_roster: ChildRoster,
1799    #[cfg(target_os = "linux")]
1800    cgroup_placement: Option<subc_cgroup::Placement>,
1801    #[cfg(test)]
1802    test_seed_stale_facts_before_enable_spawn: bool,
1803    #[cfg(test)]
1804    test_reload_exit_record_gate: Option<Arc<ReloadExitRecordGate>>,
1805}
1806
1807#[derive(Debug, Clone, PartialEq, Eq)]
1808struct SupervisedConfiguration {
1809    spec: ModuleSpec,
1810    health: HealthConfig,
1811}
1812
1813/// Shared daemon lookup table for supervised module handles.
1814///
1815/// Shared by clone between the [`Supervisor`] (which spawns processes) and the
1816/// channel-0 control handler (which verifies HELLOs and consumer route opens), so
1817/// launch nonces recorded at spawn are checked by the same daemon instance.
1818#[derive(Debug, Clone, Default)]
1819pub struct SupervisorHandle {
1820    modules: Arc<Mutex<HashMap<String, SupervisedModule>>>,
1821    /// Module ids the supervisor has taken on. An id is added BEFORE the
1822    /// module's first process is spawned and removed only when the module
1823    /// leaves the roster (see [`Self::retire`]), so it is always a superset of
1824    /// the keys of `modules`.
1825    ///
1826    /// `modules` cannot answer "is this module configured?" on its own: a
1827    /// [`SupervisedModule`] only exists once its process has been spawned, and
1828    /// a fast child can connect, register, sync its scopes and ask about them
1829    /// before the supervisor has inserted it. Answering "not configured" in that
1830    /// gap makes scope admission refuse with the terminal "will never sync"
1831    /// instead of the retryable "has not synced yet".
1832    configured_ids: Arc<Mutex<HashSet<String>>>,
1833    spawn_events: SpawnEventFeed,
1834    /// The current expected launch nonce for each reserved module_id. Set when the
1835    /// supervisor spawns the reserved module; checked when a HELLO claims that id. A
1836    /// non-reserved module never has an entry here and is never nonce-checked.
1837    /// Reserved module ids and the nonce that authorizes their next HELLO.
1838    /// `None` means RESERVED WITH NO LEGITIMATE HOLDER — a reserved module that
1839    /// has never been spawned (e.g. configured `enabled: false`) — and refuses
1840    /// every HELLO. Before this was expressible, a reserved-but-never-spawned id
1841    /// had NO entry and admitted anyone: the reservation protected the nonce
1842    /// holder, not the NAME (found live by CKCRED's canary probe registering
1843    /// against a reserved scratch id).
1844    reserved_nonces: Arc<Mutex<HashMap<String, Option<String>>>>,
1845    /// Module ids removed by an executed rescan and the unix-millisecond removal time.
1846    ///
1847    /// This is deliberately in-memory only: subc is state-free across daemon
1848    /// restarts, and the tombstone only explains the hours-after-removal window
1849    /// while this executing daemon is still alive. Do not persist it in a store.
1850    removal_tombstones: Arc<Mutex<HashMap<String, u64>>>,
1851    /// The current launch nonce for every supervised spawn. This is separate from
1852    /// reserved_nonces because consumer route.open attestation applies to all spawned
1853    /// modules, while HELLO id-squatting protection remains opt-in via `reserved`.
1854    spawn_nonces: Arc<Mutex<HashMap<String, String>>>,
1855    /// Reserved namespace prefixes mapped to the supervised owner module whose
1856    /// current spawn nonce authorizes HELLO claims below the prefix.
1857    ///
1858    /// Per §2.6 this is not a same-user security barrier: a same-user process can
1859    /// read the key file and launch nonce env. Like exact reserved ids, it prevents
1860    /// accidental collisions and lower-trust processes from squatting protected
1861    /// namespaces.
1862    reserved_prefix_owners: Arc<Mutex<HashMap<String, String>>>,
1863    /// Blue/green swaps in progress, by module id. An entry exists from just
1864    /// before the candidate process is spawned until the swap has failed, or
1865    /// has cut over and the old process is gone. While it exists, HELLO for the
1866    /// id is gated on the swap token (see [`Self::swap_hello_admission`]) and
1867    /// consumer attestation accepts both processes' nonces.
1868    swaps: Arc<Mutex<HashMap<String, OpenSwap>>>,
1869    /// Told when a swap promotes its candidate; see [`SwapPromotionObserver`].
1870    promotion_observer: PromotionObserverSlot,
1871    /// Serializes module-set reconciliation with operator lifecycle commands. Without
1872    /// this daemon-wide ordering, a rescan could retire or update a module while a
1873    /// concurrent reload still held its old handle and launch specification.
1874    operation_lock: Arc<AsyncMutex<()>>,
1875}
1876
1877/// Told when a swap has promoted its candidate to be the module's active
1878/// registration.
1879///
1880/// An ordinary HELLO runs the control plane's registration side effects (the
1881/// capability cache, the deny census, the requirement recompute) as it
1882/// registers. A swap candidate's HELLO does not, because it is not routable;
1883/// promotion is when those must run instead, and promotion happens in the
1884/// supervisor, which has no other way into the control handler.
1885pub(crate) trait SwapPromotionObserver: Send + Sync {
1886    fn swap_promoted(&self, registration: &crate::registry::ModuleRegistration);
1887}
1888
1889/// The installed [`SwapPromotionObserver`], held weakly: the observer (the
1890/// control handler) owns this handle, so a strong reference back would be a
1891/// cycle that keeps both alive.
1892#[derive(Clone, Default)]
1893struct PromotionObserverSlot(Arc<Mutex<Option<std::sync::Weak<dyn SwapPromotionObserver>>>>);
1894
1895impl fmt::Debug for PromotionObserverSlot {
1896    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1897        f.write_str("PromotionObserverSlot")
1898    }
1899}
1900
1901/// The nonces of one open swap.
1902#[derive(Debug, Clone)]
1903struct OpenSwap {
1904    /// The launch nonce minted for the candidate process. It is the swap
1905    /// token: the only thing that admits a HELLO into the candidate slot.
1906    candidate_nonce: String,
1907    /// The incumbent's launch nonce, captured when the swap opened. It is kept
1908    /// here because cutover moves the module's recorded spawn nonce to the
1909    /// candidate while the incumbent is still draining and its consumers are
1910    /// still attesting with this one.
1911    incumbent_nonce: Option<String>,
1912    /// Set once a HELLO has been admitted with the swap token, so the token
1913    /// admits one registration and cannot be replayed after cutover empties
1914    /// the candidate slot.
1915    candidate_admitted: bool,
1916}
1917
1918/// What the swap gate says about a HELLO. See
1919/// [`SupervisorHandle::swap_hello_admission`].
1920#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1921pub(crate) enum SwapHelloAdmission {
1922    /// No swap is open for the id (or the HELLO carries the incumbent's own
1923    /// nonce); the ordinary gates decide.
1924    NotSwapping,
1925    /// The HELLO carries the swap token: register it into the candidate slot.
1926    Candidate,
1927    /// A swap is open and the HELLO carries a nonce the supervisor did not
1928    /// mint for this id, no nonce, or a token already used.
1929    Refused,
1930}
1931
1932#[derive(Debug, Clone, PartialEq, Eq)]
1933pub(crate) enum ReservedHelloRejection {
1934    Exact {
1935        module_id: String,
1936    },
1937    Prefix {
1938        prefix: String,
1939        owner_module_id: String,
1940    },
1941}
1942
1943impl SupervisorHandle {
1944    pub fn new() -> Self {
1945        Self::default()
1946    }
1947
1948    pub(crate) fn spawn_snapshot(&self) -> SpawnSnapshot {
1949        self.spawn_events.snapshot()
1950    }
1951
1952    pub(crate) fn subscribe_spawns(
1953        &self,
1954        connection_id: ConnectionId,
1955        corr: u64,
1956        version: u8,
1957        since: Option<SpawnCursor>,
1958        sink: FrameSink,
1959    ) -> Result<(), SpawnSubscribeRefusal> {
1960        self.spawn_events
1961            .subscribe(connection_id, corr, version, since, sink)
1962    }
1963
1964    pub(crate) fn cancel_spawn_subscription(&self, connection_id: ConnectionId, corr: u64) -> bool {
1965        self.spawn_events.cancel(connection_id, corr)
1966    }
1967
1968    pub(crate) fn remove_spawn_subscribers(&self, connection_id: ConnectionId) {
1969        self.spawn_events.remove_connection(connection_id);
1970    }
1971
1972    #[cfg(any(test, feature = "test-support"))]
1973    pub fn set_spawn_event_capacity_for_test(&self, capacity: usize) {
1974        assert!(capacity > 0, "spawn event capacity must be non-zero");
1975        self.spawn_events.set_capacity(capacity);
1976    }
1977
1978    #[cfg(any(test, feature = "test-support"))]
1979    pub fn spawn_subscriber_count_for_test(&self) -> usize {
1980        self.spawn_events.subscriber_count()
1981    }
1982
1983    /// Record the launch nonce from a supervised spawn, replacing any prior nonce so
1984    /// a respawn invalidates stale consumer identities.
1985    pub fn set_spawn_nonce(&self, module_id: &str, nonce: String) {
1986        self.spawn_nonces
1987            .lock()
1988            .unwrap_or_else(|poisoned| poisoned.into_inner())
1989            .insert(module_id.to_string(), nonce);
1990    }
1991
1992    /// Record the launch nonce expected from the next HELLO for a reserved module,
1993    /// replacing any prior nonce (a respawn invalidates the previous one).
1994    pub fn set_reserved_nonce(&self, module_id: &str, nonce: String) {
1995        self.reserved_nonces
1996            .lock()
1997            .unwrap_or_else(|poisoned| poisoned.into_inner())
1998            .insert(module_id.to_string(), Some(nonce));
1999    }
2000
2001    /// Record namespace prefixes owned by a supervised module.
2002    pub fn set_reserved_prefixes(&self, owner_module_id: &str, prefixes: &[String]) {
2003        let mut owners = self
2004            .reserved_prefix_owners
2005            .lock()
2006            .unwrap_or_else(|poisoned| poisoned.into_inner());
2007        owners.retain(|_, owner| owner != owner_module_id);
2008        for prefix in prefixes {
2009            owners.insert(prefix.clone(), owner_module_id.to_string());
2010        }
2011    }
2012
2013    /// The launch nonce most recently minted for a module's spawn, if any.
2014    #[cfg(test)]
2015    pub(crate) fn spawn_nonce(&self, module_id: &str) -> Option<String> {
2016        self.spawn_nonces
2017            .lock()
2018            .unwrap_or_else(|poisoned| poisoned.into_inner())
2019            .get(module_id)
2020            .cloned()
2021    }
2022
2023    fn apply_identity_configuration(&self, spec: &ModuleSpec) {
2024        self.set_reserved_prefixes(&spec.module_id, &spec.reserved_prefixes);
2025        let spawn_nonce = self
2026            .spawn_nonces
2027            .lock()
2028            .unwrap_or_else(|poisoned| poisoned.into_inner())
2029            .get(&spec.module_id)
2030            .cloned();
2031        let mut reserved_nonces = self
2032            .reserved_nonces
2033            .lock()
2034            .unwrap_or_else(|poisoned| poisoned.into_inner());
2035        if spec.reserved {
2036            // `None` (no spawn nonce minted) is INSERTED, not skipped: a
2037            // reserved name whose module has never spawned has no legitimate
2038            // holder, and the entry's absence is what used to leave the name
2039            // open to the first claimant.
2040            reserved_nonces.insert(spec.module_id.clone(), spawn_nonce);
2041        }
2042        drop(reserved_nonces);
2043        // A later unreserved declaration must not silently unreserve an id that
2044        // was retained after its reserved configuration was removed. The explicit
2045        // release ceremony is the only operation that retires that gate.
2046        self.removal_tombstones
2047            .lock()
2048            .unwrap_or_else(|poisoned| poisoned.into_inner())
2049            .remove(&spec.module_id);
2050    }
2051
2052    /// Whether a HELLO claiming `module_id` is authorized. An exact reserved id is
2053    /// authorized only by its expected nonce; otherwise a matching reserved prefix
2054    /// is authorized by the owner module's current spawn nonce. Non-reserved ids
2055    /// with no matching prefix are always authorized.
2056    pub fn reserved_hello_authorized(&self, module_id: &str, presented: Option<&str>) -> bool {
2057        self.reserved_hello_rejection(module_id, presented)
2058            .is_none()
2059    }
2060
2061    pub(crate) fn reserved_hello_rejection(
2062        &self,
2063        module_id: &str,
2064        presented: Option<&str>,
2065    ) -> Option<ReservedHelloRejection> {
2066        let nonces = self
2067            .reserved_nonces
2068            .lock()
2069            .unwrap_or_else(|poisoned| poisoned.into_inner());
2070        if let Some(expected) = nonces.get(module_id) {
2071            // `None` = reserved with no legitimate holder: refuse every
2072            // presentation, because no process can hold a nonce that was never
2073            // minted. Only a real minted nonce admits, in constant time.
2074            let authorized = match expected {
2075                Some(expected) => {
2076                    presented.is_some_and(|p| constant_time_eq(expected.as_bytes(), p.as_bytes()))
2077                }
2078                None => false,
2079            };
2080            if authorized {
2081                return None;
2082            }
2083            return Some(ReservedHelloRejection::Exact {
2084                module_id: module_id.to_string(),
2085            });
2086        }
2087        drop(nonces);
2088
2089        let matched_prefix = self
2090            .reserved_prefix_owners
2091            .lock()
2092            .unwrap_or_else(|poisoned| poisoned.into_inner())
2093            .iter()
2094            .filter(|(prefix, _)| module_id.starts_with(prefix.as_str()))
2095            .max_by_key(|(prefix, _)| prefix.len())
2096            .map(|(prefix, owner)| (prefix.clone(), owner.clone()));
2097        let (prefix, owner_module_id) = matched_prefix?;
2098
2099        let authorized = presented.is_some_and(|presented| {
2100            self.spawn_nonces
2101                .lock()
2102                .unwrap_or_else(|poisoned| poisoned.into_inner())
2103                .get(&owner_module_id)
2104                .is_some_and(|expected| constant_time_eq(expected.as_bytes(), presented.as_bytes()))
2105                // While the owner is being swapped, children started by
2106                // either of its two processes hold that process's nonce.
2107                || self.swap_nonce_matches(&owner_module_id, presented)
2108        });
2109        if authorized {
2110            None
2111        } else {
2112            Some(ReservedHelloRejection::Prefix {
2113                prefix,
2114                owner_module_id,
2115            })
2116        }
2117    }
2118
2119    /// Whether a consumer connection proved it came from a daemon-spawned module.
2120    ///
2121    /// Absence of an expected spawn nonce is a hard failure: consumer_identity is
2122    /// accepted only for module ids the supervisor has spawned.
2123    pub fn spawned_consumer_authorized(&self, module_id: &str, presented: &str) -> bool {
2124        if presented.is_empty() {
2125            return false;
2126        }
2127        let nonces = self
2128            .spawn_nonces
2129            .lock()
2130            .unwrap_or_else(|poisoned| poisoned.into_inner());
2131        let current = nonces
2132            .get(module_id)
2133            .is_some_and(|expected| constant_time_eq(expected.as_bytes(), presented.as_bytes()));
2134        drop(nonces);
2135        // During a swap two processes of the module are alive, and a consumer
2136        // started by either one presents that process's nonce. Accepting only
2137        // the recorded one would fail the incumbent's consumers for the whole
2138        // overlap once cutover moves the record to the candidate.
2139        current || self.swap_nonce_matches(module_id, presented)
2140    }
2141
2142    /// Whether `presented` is either nonce of an open swap for `module_id`.
2143    fn swap_nonce_matches(&self, module_id: &str, presented: &str) -> bool {
2144        let swaps = self
2145            .swaps
2146            .lock()
2147            .unwrap_or_else(|poisoned| poisoned.into_inner());
2148        swaps.get(module_id).is_some_and(|swap| {
2149            constant_time_eq(swap.candidate_nonce.as_bytes(), presented.as_bytes())
2150                || swap.incumbent_nonce.as_deref().is_some_and(|incumbent| {
2151                    constant_time_eq(incumbent.as_bytes(), presented.as_bytes())
2152                })
2153        })
2154    }
2155
2156    /// Open a swap for `module_id` with the candidate's freshly minted nonce.
2157    /// Called before the candidate process exists.
2158    pub(crate) fn open_swap(&self, module_id: &str, candidate_nonce: String) {
2159        let incumbent_nonce = self
2160            .spawn_nonces
2161            .lock()
2162            .unwrap_or_else(|poisoned| poisoned.into_inner())
2163            .get(module_id)
2164            .cloned();
2165        self.swaps
2166            .lock()
2167            .unwrap_or_else(|poisoned| poisoned.into_inner())
2168            .insert(
2169                module_id.to_string(),
2170                OpenSwap {
2171                    candidate_nonce,
2172                    incumbent_nonce,
2173                    candidate_admitted: false,
2174                },
2175            );
2176    }
2177
2178    /// Close the swap for `module_id`, releasing whichever nonce is no longer
2179    /// the module's recorded one.
2180    pub(crate) fn close_swap(&self, module_id: &str) {
2181        self.swaps
2182            .lock()
2183            .unwrap_or_else(|poisoned| poisoned.into_inner())
2184            .remove(module_id);
2185    }
2186
2187    /// Install the observer told about swap promotions, replacing any earlier
2188    /// one.
2189    pub(crate) fn set_swap_promotion_observer(
2190        &self,
2191        observer: std::sync::Weak<dyn SwapPromotionObserver>,
2192    ) {
2193        *self
2194            .promotion_observer
2195            .0
2196            .lock()
2197            .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(observer);
2198    }
2199
2200    /// Tell the installed observer, if it is still alive, that a swap promoted
2201    /// `registration`.
2202    fn notify_swap_promoted(&self, registration: &crate::registry::ModuleRegistration) {
2203        let observer = self
2204            .promotion_observer
2205            .0
2206            .lock()
2207            .unwrap_or_else(|poisoned| poisoned.into_inner())
2208            .as_ref()
2209            .and_then(std::sync::Weak::upgrade);
2210        if let Some(observer) = observer {
2211            observer.swap_promoted(registration);
2212        }
2213    }
2214
2215    /// Whether a swap is open for `module_id`.
2216    pub(crate) fn swap_open(&self, module_id: &str) -> bool {
2217        self.swaps
2218            .lock()
2219            .unwrap_or_else(|poisoned| poisoned.into_inner())
2220            .contains_key(module_id)
2221    }
2222
2223    /// Make the candidate's nonce the module's recorded spawn nonce, as a plain
2224    /// respawn would, once cutover has made the candidate the module's process.
2225    /// The swap stays open so the incumbent's nonce keeps attesting until the
2226    /// incumbent has drained and exited.
2227    fn promote_swap_nonce(&self, module_id: &str, reserved: bool) {
2228        let candidate_nonce = self
2229            .swaps
2230            .lock()
2231            .unwrap_or_else(|poisoned| poisoned.into_inner())
2232            .get(module_id)
2233            .map(|swap| swap.candidate_nonce.clone());
2234        let Some(nonce) = candidate_nonce else {
2235            return;
2236        };
2237        self.set_spawn_nonce(module_id, nonce.clone());
2238        if reserved {
2239            self.set_reserved_nonce(module_id, nonce);
2240        }
2241    }
2242
2243    /// The swap gate for a HELLO claiming `module_id`.
2244    ///
2245    /// This runs BEFORE the reserved-module gate. A reserved module's candidate
2246    /// presents the candidate nonce, which the reserved gate (holding the
2247    /// incumbent's nonce) would refuse as `reserved_module` before swap
2248    /// admission was ever reached. And it applies to unreserved ids too: for an
2249    /// unreserved id the only thing that ever stopped a second process claiming
2250    /// a live id was the `duplicate_module_id` refusal, which is exactly the
2251    /// refusal a swap lifts for its candidate.
2252    ///
2253    /// The incumbent's own nonce falls through to the ordinary gates, which
2254    /// treat it as they always have (a live incumbent is refused as a
2255    /// duplicate). Anything else while a swap is open is refused, including an
2256    /// absent nonce.
2257    pub(crate) fn swap_hello_admission(
2258        &self,
2259        module_id: &str,
2260        presented: Option<&str>,
2261    ) -> SwapHelloAdmission {
2262        let swaps = self
2263            .swaps
2264            .lock()
2265            .unwrap_or_else(|poisoned| poisoned.into_inner());
2266        let Some(swap) = swaps.get(module_id) else {
2267            return SwapHelloAdmission::NotSwapping;
2268        };
2269        let Some(presented) = presented else {
2270            return SwapHelloAdmission::Refused;
2271        };
2272        if constant_time_eq(swap.candidate_nonce.as_bytes(), presented.as_bytes()) {
2273            return if swap.candidate_admitted {
2274                SwapHelloAdmission::Refused
2275            } else {
2276                SwapHelloAdmission::Candidate
2277            };
2278        }
2279        if swap
2280            .incumbent_nonce
2281            .as_deref()
2282            .is_some_and(|incumbent| constant_time_eq(incumbent.as_bytes(), presented.as_bytes()))
2283        {
2284            return SwapHelloAdmission::NotSwapping;
2285        }
2286        SwapHelloAdmission::Refused
2287    }
2288
2289    /// Record that the swap token has registered a candidate, so it admits no
2290    /// second HELLO.
2291    pub(crate) fn mark_swap_candidate_admitted(&self, module_id: &str) {
2292        if let Some(swap) = self
2293            .swaps
2294            .lock()
2295            .unwrap_or_else(|poisoned| poisoned.into_inner())
2296            .get_mut(module_id)
2297        {
2298            swap.candidate_admitted = true;
2299        }
2300    }
2301
2302    /// Test/support lookup for the current launch nonce of a supervised spawn.
2303    pub fn spawn_launch_nonce_for(&self, module_id: &str) -> Option<String> {
2304        self.spawn_nonces
2305            .lock()
2306            .unwrap_or_else(|poisoned| poisoned.into_inner())
2307            .get(module_id)
2308            .cloned()
2309    }
2310
2311    /// Test/support lookup for the HELLO-gating nonce of a reserved module.
2312    pub fn reserved_launch_nonce_for(&self, module_id: &str) -> Option<String> {
2313        self.reserved_nonces
2314            .lock()
2315            .unwrap_or_else(|poisoned| poisoned.into_inner())
2316            .get(module_id)
2317            .cloned()
2318            .flatten()
2319    }
2320
2321    pub fn insert(&self, module: SupervisedModule) -> Option<SupervisedModule> {
2322        // Normally already marked before the process was spawned; marking here
2323        // too keeps `configured_ids` a superset of the roster for any caller
2324        // that inserts a module directly.
2325        self.mark_configured(module.module_id());
2326        let mut modules = self
2327            .modules
2328            .lock()
2329            .unwrap_or_else(|poisoned| poisoned.into_inner());
2330        modules.insert(module.module_id().to_string(), module)
2331    }
2332
2333    /// Record that the supervisor has taken on `module_id`. Called before the
2334    /// module's first process is spawned, so that by the time that process can
2335    /// register, [`Self::is_configured`] already answers true.
2336    fn mark_configured(&self, module_id: &str) {
2337        self.configured_ids
2338            .lock()
2339            .unwrap_or_else(|poisoned| poisoned.into_inner())
2340            .insert(module_id.to_string());
2341    }
2342
2343    /// Undo [`Self::mark_configured`] for a module whose first spawn failed
2344    /// before it was ever put on the roster. A module already on the roster
2345    /// keeps its mark: only [`Self::retire`] takes a rostered module off.
2346    fn unmark_configured_unless_rostered(&self, module_id: &str) {
2347        let modules = self
2348            .modules
2349            .lock()
2350            .unwrap_or_else(|poisoned| poisoned.into_inner());
2351        if !modules.contains_key(module_id) {
2352            self.configured_ids
2353                .lock()
2354                .unwrap_or_else(|poisoned| poisoned.into_inner())
2355                .remove(module_id);
2356        }
2357    }
2358
2359    /// Whether `module_id` is a module this daemon supervises: on the roster,
2360    /// or about to be (its process is being spawned right now).
2361    ///
2362    /// This, not `get(..).is_some()`, is what "the owner is configured" means
2363    /// for scopes: a supervised module's process can register and sync before
2364    /// [`Self::get`] can return it, and in that window it is still a module
2365    /// that will sync, not one that never will.
2366    pub(crate) fn is_configured(&self, module_id: &str) -> bool {
2367        self.configured_ids
2368            .lock()
2369            .unwrap_or_else(|poisoned| poisoned.into_inner())
2370            .contains(module_id)
2371    }
2372
2373    pub fn get(&self, module_id: &str) -> Option<SupervisedModule> {
2374        let modules = self
2375            .modules
2376            .lock()
2377            .unwrap_or_else(|poisoned| poisoned.into_inner());
2378        modules.get(module_id).cloned()
2379    }
2380
2381    pub(crate) fn record_late_health_answer(
2382        &self,
2383        module_id: &str,
2384        latency_ms: u64,
2385    ) -> Result<bool, SuperviseError> {
2386        let Some(module) = self.get(module_id) else {
2387            return Ok(false);
2388        };
2389        update_snapshot(&module.inner.snapshot, Some(module_id), |state| {
2390            state.health.late_answer_count = state.health.late_answer_count.saturating_add(1);
2391            state.health.last_late_answer_latency_ms = Some(latency_ms);
2392            // A late answer is an answer: the module served the probe, just past
2393            // the deadline. Leaving the miss streak in place while logging
2394            // "proves the module is alive" is how a CPU-starved module that
2395            // answers every probe a few seconds late still marches to the
2396            // threshold and gets killed — the exact kill class `NoAnswer` is
2397            // excluded from `is_proof_of_death` to prevent. Slow-but-answering
2398            // is degradation, and degradation reports; it does not restart.
2399            state.health.consecutive_failures = 0;
2400        })?;
2401        Ok(true)
2402    }
2403
2404    /// Arm the one-shot marker for the module process that this caller
2405    /// deliberately initiated severance against. Generic connection teardown
2406    /// must not call this:
2407    /// a surviving process would otherwise retain an exemption for a later
2408    /// genuine crash.
2409    pub fn record_deliberate_severance(&self, module_id: &str) -> Result<bool, SuperviseError> {
2410        let Some(module) = self.get(module_id) else {
2411            return Ok(false);
2412        };
2413        let snapshot = lock_snapshot(&module.inner.snapshot)?;
2414        let Some((pid, start_time)) = snapshot.pid.zip(snapshot.process_start_time) else {
2415            return Ok(false);
2416        };
2417        drop(snapshot);
2418        module.record_deliberate_severance(ProcessIdentity { pid, start_time })
2419    }
2420
2421    pub fn list(&self) -> Vec<SupervisedModule> {
2422        let modules = self
2423            .modules
2424            .lock()
2425            .unwrap_or_else(|poisoned| poisoned.into_inner());
2426        let mut modules = modules.values().cloned().collect::<Vec<_>>();
2427        modules.sort_by(|left, right| left.module_id().cmp(right.module_id()));
2428        modules
2429    }
2430
2431    pub(crate) fn retire(&self, module_id: &str) -> Option<SupervisedModule> {
2432        self.spawn_nonces
2433            .lock()
2434            .unwrap_or_else(|poisoned| poisoned.into_inner())
2435            .remove(module_id);
2436        self.close_swap(module_id);
2437        let mut reserved_nonces = self
2438            .reserved_nonces
2439            .lock()
2440            .unwrap_or_else(|poisoned| poisoned.into_inner());
2441        if reserved_nonces.contains_key(module_id) {
2442            // The old nonce must die with the removed process, but the exact-id
2443            // gate remains until an operator explicitly releases it.
2444            reserved_nonces.insert(module_id.to_string(), None);
2445        }
2446        drop(reserved_nonces);
2447        self.reserved_prefix_owners
2448            .lock()
2449            .unwrap_or_else(|poisoned| poisoned.into_inner())
2450            .retain(|_, owner| owner != module_id);
2451        let removed = self
2452            .modules
2453            .lock()
2454            .unwrap_or_else(|poisoned| poisoned.into_inner())
2455            .remove(module_id);
2456        self.configured_ids
2457            .lock()
2458            .unwrap_or_else(|poisoned| poisoned.into_inner())
2459            .remove(module_id);
2460        removed
2461    }
2462
2463    /// Remember a module removed by a non-preview rescan so route.open can
2464    /// distinguish that intentional removal from an unknown id.
2465    pub(crate) fn record_rescan_removal(&self, module_id: &str) {
2466        self.removal_tombstones
2467            .lock()
2468            .unwrap_or_else(|poisoned| poisoned.into_inner())
2469            .insert(module_id.to_string(), unix_ms_now());
2470    }
2471
2472    /// Return how long ago a rescan removed this module in milliseconds.
2473    pub(crate) fn removal_tombstone_age_ms(&self, module_id: &str) -> Option<u64> {
2474        self.removal_tombstones
2475            .lock()
2476            .unwrap_or_else(|poisoned| poisoned.into_inner())
2477            .get(module_id)
2478            .copied()
2479            .map(|removed_at_ms| unix_ms_now().saturating_sub(removed_at_ms))
2480    }
2481
2482    /// Retire a reserved-id gate only after its module has left supervision.
2483    ///
2484    /// A retained gate has no live nonce (`None`), so releasing any other entry
2485    /// would weaken a currently configured or otherwise active reservation.
2486    pub(crate) fn release_retained_reserved_gate(&self, module_id: &str) -> bool {
2487        if self.get(module_id).is_some() {
2488            return false;
2489        }
2490        let mut reserved_nonces = self
2491            .reserved_nonces
2492            .lock()
2493            .unwrap_or_else(|poisoned| poisoned.into_inner());
2494        if !matches!(reserved_nonces.get(module_id), Some(None)) {
2495            return false;
2496        }
2497        reserved_nonces.remove(module_id);
2498        true
2499    }
2500
2501    pub(crate) fn operation_lock(&self) -> Arc<AsyncMutex<()>> {
2502        Arc::clone(&self.operation_lock)
2503    }
2504}
2505
2506/// Process supervisor for subc-owned singleton modules.
2507#[derive(Debug, Clone)]
2508pub struct Supervisor {
2509    registry: Arc<Registry>,
2510    restart_policy: RestartPolicy,
2511    drain_timeout: Duration,
2512    connection_file_path: Option<PathBuf>,
2513    capture_logs_dir: Option<PathBuf>,
2514    forwarding: Option<Arc<ForwardingTable>>,
2515    process_liveness: Arc<SupervisorProcessLiveness>,
2516    supervisor_handle: Option<SupervisorHandle>,
2517    health: HealthConfig,
2518    daemon_start_clock: crate::clock::StartClock,
2519    terminal_journal: Option<Arc<crate::terminal_journal::TerminalJournal>>,
2520    spawn_events: SpawnEventFeed,
2521    provenance_probe: ExecutableIdentityProbe,
2522    /// Every process spawned through this supervisor (and its clones) and not
2523    /// yet reaped, so daemon shutdown can end them.
2524    child_roster: ChildRoster,
2525    #[cfg(target_os = "linux")]
2526    cgroup_placement: Option<subc_cgroup::Placement>,
2527    #[cfg(test)]
2528    test_after_first_spawn: AfterFirstSpawnHook,
2529}
2530
2531/// Test-only hook run on the path that takes on a new module, right after its
2532/// first `spawn_child` returns (the process exists and could already be
2533/// registering) and before that process is handed to the module's supervise
2534/// loop and put on the roster. Lets a test observe what a fast child would see
2535/// in that window without racing a real one.
2536#[cfg(test)]
2537#[derive(Clone, Default)]
2538struct AfterFirstSpawnHook(Option<AfterFirstSpawnFn>);
2539
2540#[cfg(test)]
2541type AfterFirstSpawnFn = Arc<dyn Fn(&str) + Send + Sync>;
2542
2543#[cfg(test)]
2544impl fmt::Debug for AfterFirstSpawnHook {
2545    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2546        f.write_str("AfterFirstSpawnHook")
2547    }
2548}
2549
2550#[cfg(test)]
2551impl AfterFirstSpawnHook {
2552    fn run(&self, module_id: &str) {
2553        if let Some(hook) = &self.0 {
2554            hook(module_id);
2555        }
2556    }
2557}
2558
2559impl Supervisor {
2560    #[cfg(test)]
2561    pub(crate) fn new_for_test(registry: Arc<Registry>, policy: RestartPolicy) -> Self {
2562        let supervisor = Self::new(registry, policy);
2563        #[cfg(target_os = "macos")]
2564        let supervisor = supervisor.with_privacy_trampoline(test_privacy_trampoline());
2565        supervisor
2566    }
2567    /// Verify the trampoline once when configured. Missing private OS support
2568    /// refuses every macOS launch by name but does not stop the daemon's control
2569    /// server. Embedders must explicitly provide a binary with the subc-os hidden
2570    /// entry point; the library must not exec an arbitrary hosting program.
2571    pub fn with_privacy_trampoline(self, path: impl Into<PathBuf>) -> Self {
2572        let path = path.into();
2573        #[cfg(target_os = "macos")]
2574        {
2575            let result = probe_privacy_trampoline(&path).map(|()| path);
2576            if let Err(cause) = &result {
2577                error!(%cause, "macOS privacy identity unavailable; supervised launches will refuse");
2578            }
2579            self.child_roster.set_privacy_trampoline(result);
2580        }
2581        #[cfg(not(target_os = "macos"))]
2582        let _ = path;
2583        self
2584    }
2585    /// The first step of an announced daemon shutdown, before the notice and
2586    /// before any connection is closed.
2587    ///
2588    /// Sets the daemon-shutdown flag first: from here on no module is
2589    /// respawned (crash restart, operator restart, or swap), and every child
2590    /// exit is recorded as `daemon_shutdown` rather than as a crash, whether
2591    /// the module exits on the EOF this shutdown gives it or is signalled by a
2592    /// service manager that kills the whole cgroup. Then writes the journal's
2593    /// shutdown marker, which records the instant and closes this daemon
2594    /// incarnation's stretch of the journal.
2595    #[cfg(unix)]
2596    pub(crate) fn begin_daemon_shutdown(&self) {
2597        self.child_roster.close();
2598        if let Some(journal) = &self.terminal_journal {
2599            journal.stamp_shutdown();
2600        }
2601    }
2602
2603    /// Announce a cut while established connections can still carry replies.
2604    /// These budgets promise notice and a bounded wait, not child completion;
2605    /// they are local policy, not an estimate of launchd's unknown kill ceiling.
2606    #[cfg(unix)]
2607    pub(crate) async fn drain_for_daemon_shutdown(&self) -> Result<(), SuperviseError> {
2608        const NOTICE_BUDGET: Duration = Duration::from_millis(500);
2609        const DRAIN_BUDGET: Duration = Duration::from_secs(2);
2610        let Some(forwarding) = &self.forwarding else {
2611            return Ok(());
2612        };
2613        let modules = forwarding
2614            .begin_daemon_drain()
2615            .map_err(SuperviseError::Forwarding)?;
2616        let deadline_ms =
2617            unix_ms_now().saturating_add((NOTICE_BUDGET + DRAIN_BUDGET).as_millis() as u64);
2618        let mut notices = tokio::task::JoinSet::new();
2619        let mut drains = Vec::new();
2620        for (module_id, endpoint) in modules {
2621            let Some(target) = forwarding
2622                .begin_endpoint_drain(endpoint, RouteCloseReason::Restart)
2623                .map_err(SuperviseError::Forwarding)?
2624            else {
2625                continue;
2626            };
2627            let routes = forwarding
2628                .endpoint_routes(target.endpoint)
2629                .map_err(SuperviseError::Forwarding)?;
2630            // Restart allows deployed consumers to reopen after the new daemon
2631            // appears. The wire reason stays `restart`; what tells a daemon cut
2632            // apart from a module restart afterwards is the terminal record
2633            // itself, whose disposition is `daemon_shutdown` for every exit
2634            // observed once `begin_daemon_shutdown` has run.
2635            let command = serde_json::to_vec(&ModuleControlCommand::Draining {
2636                reason: RouteCloseReason::Restart,
2637                deadline_ms,
2638            })
2639            .expect("module draining serializes");
2640            let mut recipients = vec![(target.sink.clone(), target.negotiated_ver, command)];
2641            let mut clients: Vec<(crate::forwarding::GoodbyeTarget, Vec<u16>)> = Vec::new();
2642            for route in routes {
2643                let client = route.goodbye_target;
2644                if let Some((_, channels)) = clients
2645                    .iter_mut()
2646                    .find(|(existing, _)| existing.connection_id == client.connection_id)
2647                {
2648                    channels.push(client.channel);
2649                } else {
2650                    let channel = client.channel;
2651                    clients.push((client, vec![channel]));
2652                }
2653            }
2654            for (client, mut channels) in clients {
2655                channels.sort_unstable();
2656                channels.dedup();
2657                let closing = serde_json::to_vec(&ClientControlPush::RouteClosing {
2658                    module_id: module_id.clone(),
2659                    channels,
2660                    reason: RouteCloseReason::Restart,
2661                })
2662                .expect("route closing serializes");
2663                recipients.push((client.sink, client.negotiated_ver, closing));
2664            }
2665            for (sink, version, body) in recipients {
2666                notices.spawn(async move {
2667                    let frame = Frame::build_with_version(
2668                        version,
2669                        FrameType::Push,
2670                        control_flags(),
2671                        0,
2672                        0,
2673                        0,
2674                        body,
2675                    )
2676                    .expect("bounded lifecycle notice frame builds");
2677                    sink.send_flushed(frame).await
2678                });
2679            }
2680            let gauges = declared_busy_gauges(&self.registry, &module_id)?;
2681            drains.push((module_id, target.endpoint, gauges));
2682        }
2683        // A quiet forwarding table is not proof that queued notices reached the
2684        // socket. Wait for writer flush acknowledgements before testing quiescence.
2685        let notice_deadline = Instant::now() + NOTICE_BUDGET;
2686        while let Ok(Some(result)) = timeout_at(notice_deadline, notices.join_next()).await {
2687            if !matches!(result, Ok(Ok(()))) {
2688                warn!(?result, "daemon shutdown notice delivery failed");
2689            }
2690        }
2691        notices.abort_all();
2692        let deadline = Instant::now() + DRAIN_BUDGET;
2693        let mut waits = tokio::task::JoinSet::new();
2694        for (module_id, endpoint, gauges) in drains {
2695            let forwarding = Arc::clone(forwarding);
2696            let mut runtime = self.runtime_config();
2697            runtime.health.cadence = Duration::from_millis(100);
2698            waits.spawn(async move {
2699                let wait_result = wait_for_forwarding_quiescence(
2700                    &forwarding,
2701                    &module_id,
2702                    &runtime,
2703                    endpoint,
2704                    deadline,
2705                    &gauges,
2706                    DrainScope::Endpoint(endpoint),
2707                )
2708                .await;
2709                // Store the measurement before the task returns: quiescence
2710                // may be reached just as the deadline stops the join loop from
2711                // collecting results. Unfinished or failed waits report false.
2712                forwarding
2713                    .record_daemon_drain_result(
2714                        endpoint,
2715                        drained_after_quiescence_wait(&wait_result),
2716                    )
2717                    .map_err(SuperviseError::Forwarding)?;
2718                wait_result
2719            });
2720        }
2721        while let Ok(Some(result)) = timeout_at(deadline, waits.join_next()).await {
2722            if !matches!(result, Ok(Ok(true))) {
2723                warn!(?result, "daemon shutdown drain did not reach quiescence");
2724            }
2725        }
2726        Ok(())
2727    }
2728
2729    /// The last step of an announced daemon shutdown, after the notice and the
2730    /// drain: send every registered module a module GOODBYE, the same planned
2731    /// stop signal `ck module stop` gives, then close every connection so each
2732    /// subc module sees EOF and starts its own teardown, then end every
2733    /// supervised child that has not exited
2734    /// by its own deadline (its drain budget, capped). Modules lead their own
2735    /// process groups, so a
2736    /// service manager's group kill no longer reaches them; without this a
2737    /// child that does not stop on EOF (every `protocol: "none"` child, which
2738    /// has no connection) would outlive the daemon. Every wait is bounded (see
2739    /// `child_roster`), and `escalate` resolving (a second SIGTERM) cuts them.
2740    #[cfg(unix)]
2741    pub(crate) async fn end_children_for_daemon_shutdown(
2742        &self,
2743        already_escalated: bool,
2744        escalate: impl std::future::Future<Output = ()>,
2745    ) {
2746        tokio::pin!(escalate);
2747        let mut escalated = already_escalated;
2748        if let Some(forwarding) = &self.forwarding {
2749            let reason = CloseReason::new(
2750                "daemon_shutdown",
2751                "the daemon is exiting after its shutdown notice and drain",
2752            );
2753            if escalated {
2754                // The operator asked to stop waiting: queue the GOODBYEs but
2755                // do not wait for them to be written.
2756                send_module_goodbyes_for_daemon_shutdown(forwarding, &reason, false).await;
2757            } else {
2758                tokio::select! {
2759                    biased;
2760                    _ = escalate.as_mut() => {
2761                        info!("second SIGTERM: abandoning module GOODBYE delivery");
2762                        escalated = true;
2763                    }
2764                    _ = send_module_goodbyes_for_daemon_shutdown(forwarding, &reason, true) => {}
2765                }
2766            }
2767            let closed = forwarding.close_all_connections(&reason);
2768            debug!(closed, "closed established connections for daemon shutdown");
2769        }
2770        // If the second SIGTERM arrived during GOODBYE delivery, `escalate` has
2771        // already completed and must not be polled again; the child shutdown
2772        // wait is told it is escalated and gets a future that never fires.
2773        let escalated_here = escalated && !already_escalated;
2774        let remaining_escalate = async move {
2775            if escalated_here {
2776                std::future::pending::<()>().await;
2777            } else {
2778                escalate.await;
2779            }
2780        };
2781        crate::child_roster::end_children_for_daemon_shutdown(
2782            &self.child_roster,
2783            escalated,
2784            remaining_escalate,
2785        )
2786        .await;
2787    }
2788
2789    pub fn new(registry: Arc<Registry>, restart_policy: RestartPolicy) -> Self {
2790        Self {
2791            registry,
2792            restart_policy,
2793            drain_timeout: DEFAULT_DRAIN_TIMEOUT,
2794            connection_file_path: None,
2795            capture_logs_dir: None,
2796            forwarding: None,
2797            process_liveness: Arc::new(SupervisorProcessLiveness::default()),
2798            supervisor_handle: None,
2799            health: HealthConfig::default(),
2800            daemon_start_clock: crate::clock::StartClock::capture(),
2801            terminal_journal: None,
2802            spawn_events: SpawnEventFeed::default(),
2803            provenance_probe: ExecutableIdentityProbe::default(),
2804            child_roster: ChildRoster::default(),
2805            #[cfg(target_os = "linux")]
2806            cgroup_placement: None,
2807            #[cfg(test)]
2808            test_after_first_spawn: AfterFirstSpawnHook::default(),
2809        }
2810    }
2811
2812    pub fn with_drain_timeout(mut self, drain_timeout: Duration) -> Self {
2813        self.drain_timeout = drain_timeout;
2814        self
2815    }
2816
2817    pub fn with_process_liveness(
2818        mut self,
2819        process_liveness: Arc<SupervisorProcessLiveness>,
2820    ) -> Self {
2821        self.process_liveness = process_liveness;
2822        self
2823    }
2824
2825    pub fn with_connection_file_path(mut self, connection_file_path: impl Into<PathBuf>) -> Self {
2826        self.connection_file_path = Some(connection_file_path.into());
2827        self
2828    }
2829
2830    /// Enables daemon-owned capture files for supervised stdout and stderr.
2831    pub fn with_capture_logs_dir(mut self, logs_dir: impl Into<PathBuf>) -> Self {
2832        self.capture_logs_dir = Some(logs_dir.into());
2833        self
2834    }
2835
2836    /// Names this daemon lifetime in spawn events, independently of whether a
2837    /// terminal journal is configured.
2838    pub fn with_daemon_incarnation(self, daemon_incarnation: String) -> Self {
2839        // A millisecond start stamp can repeat after clock rollback or a rapid
2840        // restart. Use the connection file's random daemon_id instead: it already
2841        // identifies this daemon lifetime independently of the wall clock.
2842        self.spawn_events.configure_incarnation(daemon_incarnation);
2843        self
2844    }
2845
2846    /// Enables best-effort history shared by every supervised module. Without
2847    /// it, terminal history is kept only in each module's in-memory ring.
2848    pub fn with_terminal_journal(self, path: PathBuf, daemon_incarnation: String) -> Self {
2849        let mut this = self.with_daemon_incarnation(daemon_incarnation.clone());
2850        this.terminal_journal = Some(Arc::new(crate::terminal_journal::TerminalJournal::open(
2851            path,
2852            daemon_incarnation,
2853        )));
2854        this
2855    }
2856
2857    pub fn with_forwarding(mut self, forwarding: Arc<ForwardingTable>) -> Self {
2858        self.forwarding = Some(forwarding);
2859        self
2860    }
2861
2862    pub fn with_handle(mut self, supervisor_handle: SupervisorHandle) -> Self {
2863        self.spawn_events = supervisor_handle.spawn_events.clone();
2864        self.supervisor_handle = Some(supervisor_handle);
2865        self
2866    }
2867
2868    pub fn with_health_config(mut self, health: HealthConfig) -> Self {
2869        self.health = health;
2870        self
2871    }
2872
2873    /// Keeps a record of every live child at `path`, rewritten on each spawn and
2874    /// reap, for the orphan sweep a later daemon runs at boot. Without it no
2875    /// record is kept.
2876    pub fn with_live_children_record(self, path: impl Into<PathBuf>) -> Self {
2877        self.child_roster.record_to(path.into());
2878        self
2879    }
2880
2881    #[cfg(target_os = "linux")]
2882    pub fn with_cgroup_placement(
2883        mut self,
2884        cgroup_placement: Option<subc_cgroup::Placement>,
2885    ) -> Self {
2886        self.cgroup_placement = cgroup_placement;
2887        self
2888    }
2889
2890    /// Spawn `spec.program` and start monitoring it.
2891    ///
2892    /// The child is expected to parse `--subc <connection-file-path>`, read the
2893    /// TCP+key connection file, authenticate to the already-running listener, and
2894    /// register with channel-0 `HELLO` using `spec.module_id` as its manifest id.
2895    pub fn spawn(&self, spec: ModuleSpec) -> Result<SupervisedModule, SuperviseError> {
2896        validate_spec(&spec)?;
2897        self.establish_identity(&spec);
2898
2899        let runtime = self.runtime_config();
2900        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
2901        let spawned = spawn_child(
2902            &spec,
2903            runtime.connection_file_path.as_deref(),
2904            self.supervisor_handle.as_ref(),
2905            &runtime.stderr_ring,
2906            runtime.capture_logs_dir.as_deref(),
2907            &runtime.child_roster,
2908            #[cfg(target_os = "linux")]
2909            runtime.cgroup_placement.as_ref(),
2910        );
2911        #[cfg(test)]
2912        self.test_after_first_spawn.run(&spec.module_id);
2913        let child = match spawned {
2914            Ok(child) => child,
2915            Err(err) => {
2916                // Unlike the configured paths, a failed `spawn` leaves nothing
2917                // on the roster, so the module must not stay marked configured.
2918                self.abandon_unrostered(&spec.module_id);
2919                return Err(err);
2920            }
2921        };
2922        self.mark_first_process_running(&spec, &runtime, &snapshot, &child)?;
2923
2924        Ok(self.supervised_module(spec, runtime, snapshot, Some(child)))
2925    }
2926
2927    /// Make `spec`'s module count as configured, with its identity gates
2928    /// (reserved id, reserved prefixes) in place, BEFORE any process of it
2929    /// exists.
2930    ///
2931    /// Every path that takes on a new module calls this before `spawn_child`.
2932    /// The order is the point: the child can connect, register, sync its
2933    /// scopes and ask about them as soon as it is spawned, and the module is
2934    /// only put on the roster after `spawn_child` returns. Were the mark set
2935    /// with the roster entry, a fast child would see its own owner reported
2936    /// as not configured, and a scoped `route.open` in that window would be
2937    /// refused as terminal `scope_not_live` ("will never sync") instead of
2938    /// retryable `scope_not_synced`.
2939    fn establish_identity(&self, spec: &ModuleSpec) {
2940        if let Some(supervisor_handle) = &self.supervisor_handle {
2941            supervisor_handle.apply_identity_configuration(spec);
2942            supervisor_handle.mark_configured(&spec.module_id);
2943        }
2944    }
2945
2946    /// Take back [`Self::establish_identity`]'s configured mark when the
2947    /// module will not be put on the roster after all.
2948    fn abandon_unrostered(&self, module_id: &str) {
2949        if let Some(supervisor_handle) = &self.supervisor_handle {
2950            supervisor_handle.unmark_configured_unless_rostered(module_id);
2951        }
2952    }
2953
2954    /// Record a freshly spawned first process as running. On failure the
2955    /// module never reaches the roster, so its configured mark is taken back.
2956    fn mark_first_process_running(
2957        &self,
2958        spec: &ModuleSpec,
2959        runtime: &SupervisorRuntimeConfig,
2960        snapshot: &SharedSnapshot,
2961        child: &SupervisedChild,
2962    ) -> Result<(), SuperviseError> {
2963        if let Err(err) = set_running(snapshot, child, &spec.module_id, &runtime.spawn_events) {
2964            self.abandon_unrostered(&spec.module_id);
2965            return Err(err);
2966        }
2967        self.process_liveness
2968            .track(spec.module_id.clone(), Arc::clone(snapshot));
2969        Ok(())
2970    }
2971
2972    /// Start supervising a module declared in daemon configuration.
2973    ///
2974    /// Unlike [`Self::spawn`], this records disabled modules and immediate spawn
2975    /// failures in the supervisor handle so operator-facing `supervisor.list`
2976    /// reflects every configured module while daemon startup continues.
2977    pub fn supervise_configured(
2978        &self,
2979        spec: ModuleSpec,
2980        enabled: bool,
2981    ) -> Result<SupervisedModule, SuperviseError> {
2982        validate_spec(&spec)?;
2983        self.establish_identity(&spec);
2984
2985        let runtime = self.runtime_config();
2986        if !enabled {
2987            let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::disabled()));
2988            return Ok(self.supervised_module(spec, runtime, snapshot, None));
2989        }
2990
2991        #[cfg(target_os = "linux")]
2992        if let Some(pid) = runtime.child_roster.previous_instance_pid(&spec.module_id) {
2993            let mut snapshot = SupervisorSnapshot::starting();
2994            snapshot.waiting_for_previous_pid = Some(pid);
2995            return Ok(self.supervised_module(spec, runtime, Arc::new(Mutex::new(snapshot)), None));
2996        }
2997
2998        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
2999        let spawned = spawn_child(
3000            &spec,
3001            runtime.connection_file_path.as_deref(),
3002            self.supervisor_handle.as_ref(),
3003            &runtime.stderr_ring,
3004            runtime.capture_logs_dir.as_deref(),
3005            &runtime.child_roster,
3006            #[cfg(target_os = "linux")]
3007            runtime.cgroup_placement.as_ref(),
3008        );
3009        #[cfg(test)]
3010        self.test_after_first_spawn.run(&spec.module_id);
3011        match spawned {
3012            Ok(child) => {
3013                self.mark_first_process_running(&spec, &runtime, &snapshot, &child)?;
3014                Ok(self.supervised_module(spec, runtime, snapshot, Some(child)))
3015            }
3016            Err(err) => {
3017                error!(
3018                    module_id = %spec.module_id,
3019                    program = %spec.program.display(),
3020                    error = %err,
3021                    "configured module failed to spawn; marking failed and continuing"
3022                );
3023                let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::failed()));
3024                Ok(self.supervised_module(spec, runtime, snapshot, None))
3025            }
3026        }
3027    }
3028
3029    /// Supervise a configured module with its own health, drain, and crash
3030    /// budget. The restart policy is per-module because the config file is:
3031    /// `modules.<id>.restart` resolves to a full policy at parse time, and a
3032    /// module that is expensive to restart should not be forced onto the same
3033    /// budget as one that is cheap.
3034    pub fn supervise_configured_with_health(
3035        &self,
3036        spec: ModuleSpec,
3037        enabled: bool,
3038        health: HealthConfig,
3039        drain_timeout_ms: Option<u64>,
3040        restart_policy: RestartPolicy,
3041    ) -> Result<SupervisedModule, SuperviseError> {
3042        validate_spec(&spec)?;
3043        self.establish_identity(&spec);
3044
3045        let mut runtime = self.runtime_config();
3046        runtime.health = health.clone();
3047        runtime.restart_policy = restart_policy;
3048        if let Some(ms) = drain_timeout_ms {
3049            runtime.drain_timeout = Duration::from_millis(ms);
3050            *runtime
3051                .effective_drain_timeout
3052                .lock()
3053                .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.drain_timeout;
3054        }
3055        if !enabled {
3056            let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::disabled()));
3057            return Ok(self.supervised_module(spec, runtime, snapshot, None));
3058        }
3059
3060        #[cfg(target_os = "linux")]
3061        if let Some(pid) = runtime.child_roster.previous_instance_pid(&spec.module_id) {
3062            let mut snapshot = SupervisorSnapshot::starting();
3063            snapshot.waiting_for_previous_pid = Some(pid);
3064            return Ok(self.supervised_module(spec, runtime, Arc::new(Mutex::new(snapshot)), None));
3065        }
3066
3067        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
3068        let spawned = spawn_child(
3069            &spec,
3070            runtime.connection_file_path.as_deref(),
3071            self.supervisor_handle.as_ref(),
3072            &runtime.stderr_ring,
3073            runtime.capture_logs_dir.as_deref(),
3074            &runtime.child_roster,
3075            #[cfg(target_os = "linux")]
3076            runtime.cgroup_placement.as_ref(),
3077        );
3078        #[cfg(test)]
3079        self.test_after_first_spawn.run(&spec.module_id);
3080        match spawned {
3081            Ok(child) => {
3082                self.mark_first_process_running(&spec, &runtime, &snapshot, &child)?;
3083                Ok(self.supervised_module(spec, runtime, snapshot, Some(child)))
3084            }
3085            Err(err) => {
3086                if health.critical {
3087                    error!(
3088                        module_id = %spec.module_id,
3089                        program = %spec.program.display(),
3090                        error = %err,
3091                        "critical configured module failed to spawn; marking failed and alerting"
3092                    );
3093                } else {
3094                    error!(
3095                        module_id = %spec.module_id,
3096                        program = %spec.program.display(),
3097                        error = %err,
3098                        "configured module failed to spawn; marking failed and continuing"
3099                    );
3100                }
3101                let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::failed()));
3102                Ok(self.supervised_module(spec, runtime, snapshot, None))
3103            }
3104        }
3105    }
3106
3107    fn runtime_config(&self) -> SupervisorRuntimeConfig {
3108        let effective_drain_timeout = Arc::new(Mutex::new(self.drain_timeout));
3109        SupervisorRuntimeConfig {
3110            scheduled_respawn: Arc::default(),
3111            deferred_reload_reply: Arc::default(),
3112            restart_policy: self.restart_policy,
3113            drain_timeout: self.drain_timeout,
3114            // Shared with this module's roster copy: daemon shutdown waits on
3115            // each child for the module's own drain budget, as resolved now.
3116            child_roster: self
3117                .child_roster
3118                .for_module(Arc::clone(&effective_drain_timeout)),
3119            effective_drain_timeout,
3120            default_drain_timeout: self.drain_timeout,
3121            health: self.health.clone(),
3122            connection_file_path: self.connection_file_path.clone(),
3123            capture_logs_dir: self.capture_logs_dir.clone(),
3124            forwarding: self.forwarding.clone(),
3125            supervisor_handle: self.supervisor_handle.clone(),
3126            stderr_ring: Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default()))),
3127            terminal_ring: Arc::new(Mutex::new(
3128                TerminalRing::new(
3129                    TerminalRingConfig::default(),
3130                    self.daemon_start_clock.started_at_ms(),
3131                )
3132                .with_start_clock(self.daemon_start_clock)
3133                .with_journal(self.terminal_journal.clone())
3134                .with_daemon_shutdown(self.child_roster.shutdown_flag()),
3135            )),
3136            spawn_events: self.spawn_events.clone(),
3137            #[cfg(target_os = "linux")]
3138            cgroup_placement: self.cgroup_placement.clone(),
3139            #[cfg(test)]
3140            test_seed_stale_facts_before_enable_spawn: false,
3141            #[cfg(test)]
3142            test_reload_exit_record_gate: None,
3143        }
3144    }
3145
3146    fn supervised_module(
3147        &self,
3148        spec: ModuleSpec,
3149        runtime: SupervisorRuntimeConfig,
3150        snapshot: SharedSnapshot,
3151        child: Option<SupervisedChild>,
3152    ) -> SupervisedModule {
3153        let configuration = Arc::new(Mutex::new(SupervisedConfiguration {
3154            spec: spec.clone(),
3155            health: runtime.health.clone(),
3156        }));
3157        let stderr_ring = Arc::clone(&runtime.stderr_ring);
3158        let terminal_ring = Arc::clone(&runtime.terminal_ring);
3159        // The module's OWN policy, which may be its per-module config rather than
3160        // the supervisor-wide one; status must report the budget the supervise
3161        // loop actually enforces.
3162        let restart_policy = runtime.restart_policy;
3163        let effective_drain_timeout = Arc::clone(&runtime.effective_drain_timeout);
3164        let (tx, rx) = mpsc::channel(4);
3165        let monitor = tokio::spawn(supervise_loop(
3166            spec.clone(),
3167            runtime,
3168            Arc::clone(&self.registry),
3169            Arc::clone(&self.process_liveness),
3170            Arc::clone(&snapshot),
3171            child,
3172            rx,
3173        ));
3174
3175        let module_id = spec.module_id.clone();
3176        let module = SupervisedModule {
3177            inner: Arc::new(SupervisedModuleInner {
3178                module_id: module_id.clone(),
3179                registry: Arc::clone(&self.registry),
3180                snapshot,
3181                configuration,
3182                stderr_ring,
3183                terminal_ring,
3184                commands: tx,
3185                monitor: Mutex::new(Some(monitor)),
3186                restart_policy,
3187                effective_drain_timeout,
3188                provenance_probe: self.provenance_probe.clone(),
3189            }),
3190        };
3191        // The identity gates and the configured mark were set by
3192        // `establish_identity` before any process was spawned; only the roster
3193        // entry waits for the module handle, which needs the spawned child.
3194        if let Some(supervisor_handle) = &self.supervisor_handle {
3195            supervisor_handle.insert(module.clone());
3196        }
3197        module
3198    }
3199}
3200
3201impl Default for Supervisor {
3202    fn default() -> Self {
3203        Self::new(Arc::new(Registry::default()), RestartPolicy::default())
3204    }
3205}
3206
3207/// Handle to one supervised child process.
3208#[derive(Clone)]
3209pub struct SupervisedModule {
3210    inner: Arc<SupervisedModuleInner>,
3211}
3212
3213struct SupervisedModuleInner {
3214    module_id: String,
3215    registry: Arc<Registry>,
3216    snapshot: SharedSnapshot,
3217    configuration: Arc<Mutex<SupervisedConfiguration>>,
3218    stderr_ring: Arc<Mutex<StderrRing>>,
3219    terminal_ring: Arc<Mutex<TerminalRing>>,
3220    commands: mpsc::Sender<SupervisorCommand>,
3221    monitor: Mutex<Option<JoinHandle<()>>>,
3222    /// Copied from the supervisor's runtime config at spawn so `status()` can
3223    /// report the restart budget without reaching back into the supervisor. The
3224    /// policy is fixed for the process's lifetime, so a copy cannot drift.
3225    restart_policy: RestartPolicy,
3226    effective_drain_timeout: Arc<Mutex<Duration>>,
3227    provenance_probe: ExecutableIdentityProbe,
3228}
3229
3230impl fmt::Debug for SupervisedModule {
3231    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3232        f.debug_struct("SupervisedModule")
3233            .field("module_id", &self.inner.module_id)
3234            .field("status", &self.status())
3235            .finish_non_exhaustive()
3236    }
3237}
3238
3239impl SupervisedModule {
3240    pub fn module_id(&self) -> &str {
3241        &self.inner.module_id
3242    }
3243
3244    /// Test-only: put one probe miss on the streak, the way
3245    /// `handle_health_probe_failure` does, so tests can assert what a later
3246    /// event does to the streak without driving the whole probe loop.
3247    #[cfg(test)]
3248    pub(crate) fn record_health_probe_failure_for_test(
3249        &self,
3250        detail: &str,
3251    ) -> Result<(), SuperviseError> {
3252        update_snapshot(&self.inner.snapshot, Some(&self.inner.module_id), |state| {
3253            state.health.consecutive_failures = state.health.consecutive_failures.saturating_add(1);
3254            state.health.detail = Some(detail.to_string());
3255        })
3256    }
3257
3258    pub fn state(&self) -> Result<ModuleState, SuperviseError> {
3259        Ok(lock_snapshot(&self.inner.snapshot)?.state)
3260    }
3261
3262    pub(crate) fn registration_end_reason(
3263        &self,
3264    ) -> Result<Option<RegistrationEndReason>, SuperviseError> {
3265        let snapshot = lock_snapshot(&self.inner.snapshot)?;
3266        Ok(match snapshot.state {
3267            ModuleState::Draining if snapshot.draining_to_replace => {
3268                Some(RegistrationEndReason::SupervisorRestart)
3269            }
3270            ModuleState::Draining => Some(RegistrationEndReason::SupervisorStop),
3271            ModuleState::Restarting if snapshot.draining_to_replace => {
3272                Some(RegistrationEndReason::SupervisorRestart)
3273            }
3274            _ => None,
3275        })
3276    }
3277
3278    /// The module's retained stderr, newest lines last.
3279    ///
3280    /// Deliberately NOT on [`Self::status`]: a bounded tail is kilobytes per
3281    /// module, `supervisor.list` renders every module, and putting it in the
3282    /// shared snapshot would make each status read carry a payload almost nobody
3283    /// asked for. Callers that want the text ask for it.
3284    pub fn stderr_tail(
3285        &self,
3286        max_lines: Option<usize>,
3287        max_bytes: Option<usize>,
3288    ) -> StderrTailSnapshot {
3289        self.inner
3290            .stderr_ring
3291            .lock()
3292            .unwrap_or_else(|poisoned| poisoned.into_inner())
3293            .snapshot(max_lines, max_bytes)
3294    }
3295
3296    /// The module's bounded terminal history, oldest retained exit first.
3297    ///
3298    /// The daemon-start stamp distinguishes a quiet supervisor from a replacement
3299    /// daemon whose in-memory history was necessarily reset.
3300    pub fn terminal_history(&self) -> TerminalHistorySnapshot {
3301        self.inner
3302            .terminal_ring
3303            .lock()
3304            .unwrap_or_else(|poisoned| poisoned.into_inner())
3305            .snapshot()
3306    }
3307
3308    /// Retained observations from the current ring and all journal generations.
3309    ///
3310    /// Blocking: this reads the journal files. Async callers use
3311    /// [`Self::read_durable_terminal_history`].
3312    pub fn durable_terminal_history(&self) -> subc_control::TerminalHistory {
3313        durable_terminal_history_of(&self.inner.terminal_ring, &self.inner.module_id)
3314    }
3315
3316    /// [`Self::durable_terminal_history`] on a blocking thread, so the journal
3317    /// read (up to every retained generation) never occupies a runtime worker.
3318    /// Fails only if the blocking task could not finish (runtime shutdown or a
3319    /// panic in the read).
3320    pub(crate) async fn read_durable_terminal_history(
3321        &self,
3322    ) -> Result<subc_control::TerminalHistory, tokio::task::JoinError> {
3323        let terminal_ring = Arc::clone(&self.inner.terminal_ring);
3324        let module_id = self.inner.module_id.clone();
3325        tokio::task::spawn_blocking(move || durable_terminal_history_of(&terminal_ring, &module_id))
3326            .await
3327    }
3328
3329    pub fn status(&self) -> Result<ModuleStatus, SuperviseError> {
3330        self.status_with_snapshot_lock(&self.inner.snapshot, None)
3331            .map(|(status, _)| status)
3332    }
3333
3334    pub(crate) fn record_deliberate_severance(
3335        &self,
3336        identity: ProcessIdentity,
3337    ) -> Result<bool, SuperviseError> {
3338        let mut snapshot = lock_snapshot(&self.inner.snapshot)?;
3339        if snapshot.pid != Some(identity.pid)
3340            || snapshot.process_start_time != Some(identity.start_time)
3341        {
3342            return Ok(false);
3343        }
3344        snapshot.deliberate_severance = Some(identity);
3345        Ok(true)
3346    }
3347
3348    /// Read status for a channel-0 renderer and report a contended snapshot lock.
3349    ///
3350    /// Internal supervision callers use [`Self::status`] so writer-side machinery
3351    /// does not produce reader-observability logs.
3352    pub(crate) fn status_for_control(
3353        &self,
3354        caller: &'static str,
3355    ) -> Result<ModuleStatus, SuperviseError> {
3356        self.status_with_snapshot_lock(&self.inner.snapshot, Some(caller))
3357            .map(|(status, _)| status)
3358    }
3359
3360    fn status_with_snapshot_lock(
3361        &self,
3362        snapshot: &SharedSnapshot,
3363        caller: Option<&'static str>,
3364    ) -> Result<(ModuleStatus, Option<SpawnedFileIdentity>), SuperviseError> {
3365        let mut guard = match caller {
3366            Some(caller) => lock_snapshot_for_control(snapshot, &self.inner.module_id, caller)?,
3367            None => lock_snapshot(snapshot)?,
3368        };
3369        // Read the budget through the pruning path so a reader sees the same
3370        // in-window count the restart decision would use, not a stale total.
3371        let restart_count =
3372            guard.crash_restarts_in_window(self.inner.restart_policy.window, Instant::now());
3373        let snapshot = guard.clone();
3374        drop(guard);
3375        let drain_timeout = *self.inner.effective_drain_timeout.lock().map_err(|_| {
3376            SuperviseError::StatePoisoned {
3377                module_id: Some(self.inner.module_id.clone()),
3378            }
3379        })?;
3380        let registration_active = self
3381            .inner
3382            .registry
3383            .get_module(&self.inner.module_id)
3384            .map_err(SuperviseError::Registry)?
3385            .is_some();
3386        let protocol = snapshot
3387            .spawned_protocol
3388            .unwrap_or(self.declared_protocol()?);
3389        let running_process =
3390            snapshot.enabled && snapshot.state == ModuleState::Running && snapshot.process_alive;
3391        // Registration is the difference between the two protocols and the only
3392        // one: a subc module that has not registered cannot serve a request even
3393        // though its process is up, and a `none` module never registers at all,
3394        // so requiring it there would pin `live` to false for the whole life of
3395        // a perfectly healthy process.
3396        let live = match protocol {
3397            ModuleProtocol::Subc => running_process && registration_active,
3398            ModuleProtocol::None => running_process,
3399        };
3400
3401        Ok((
3402            ModuleStatus {
3403                waiting_for_previous_pid: snapshot.waiting_for_previous_pid,
3404                module_id: self.inner.module_id.clone(),
3405                launch_nonce_source: observed_launch_nonce_source(&snapshot),
3406                state: snapshot.state,
3407                enabled: snapshot.enabled,
3408                process_alive: snapshot.process_alive,
3409                registration_active,
3410                protocol,
3411                live,
3412                restart_count,
3413                lifetime_restarts: snapshot.lifetime_restarts,
3414                spawn_generation: snapshot.spawn_generation,
3415                max_restarts: self.inner.restart_policy.max_restarts,
3416                restart_window: self.inner.restart_policy.window,
3417                drain_timeout,
3418                restart_backoff: self.inner.restart_policy.backoff,
3419                restart_max_backoff: self.inner.restart_policy.max_backoff,
3420                pid: snapshot.reported_pid(),
3421                spawned_at_ms: snapshot.spawned_at_ms,
3422                spawned_from: snapshot.spawned_from,
3423                process_start_time: snapshot.process_start_time,
3424                last_exit: snapshot.last_exit,
3425                health: snapshot.health,
3426            },
3427            snapshot.spawned_file_identity.clone(),
3428        ))
3429    }
3430
3431    #[cfg(test)]
3432    pub(crate) fn hold_snapshot_for_test(
3433        &self,
3434        acquired: std::sync::mpsc::Sender<()>,
3435        hold: Duration,
3436    ) -> std::thread::JoinHandle<()> {
3437        let snapshot = Arc::clone(&self.inner.snapshot);
3438        std::thread::spawn(move || {
3439            let _guard = snapshot.lock().expect("test snapshot lock is not poisoned");
3440            acquired
3441                .send(())
3442                .expect("test receiver waits for snapshot lock");
3443            std::thread::sleep(hold);
3444        })
3445    }
3446
3447    /// The status and the running-image check for `supervisor.provenance`,
3448    /// taken from one status read. The exec acknowledgement can land between
3449    /// two separate reads, and the reply would then pair "no pid yet" with an
3450    /// image observed after the module started, which describes no single
3451    /// moment.
3452    pub(crate) async fn status_and_running_image_agreement(
3453        &self,
3454    ) -> Result<(ModuleStatus, subc_control::RunningImageAgreement), SuperviseError> {
3455        let (status, identity) = self.status_with_snapshot_lock(&self.inner.snapshot, None)?;
3456        let image = self
3457            .inner
3458            .provenance_probe
3459            .observe(
3460                status.pid,
3461                status.spawned_from.as_deref(),
3462                identity,
3463                status.process_start_time,
3464            )
3465            .await;
3466        Ok((status, image))
3467    }
3468
3469    pub(crate) async fn running_image_agreement(&self) -> subc_control::RunningImageAgreement {
3470        let snapshot = match lock_snapshot(&self.inner.snapshot) {
3471            Ok(snapshot) => snapshot.clone(),
3472            Err(_) => {
3473                return subc_control::RunningImageAgreement::Unavailable {
3474                    reason: subc_control::RunningImageUnavailableReason::NotRunning,
3475                };
3476            }
3477        };
3478        self.inner
3479            .provenance_probe
3480            .observe(
3481                snapshot.reported_pid(),
3482                snapshot.spawned_from.as_deref(),
3483                snapshot.spawned_file_identity.clone(),
3484                snapshot.process_start_time,
3485            )
3486            .await
3487    }
3488
3489    /// Memory and CPU time of the module's current process, read now. Only the
3490    /// process the supervisor spawned is read, not processes it has started.
3491    pub(crate) fn child_resource_usage(&self) -> subc_control::ChildResourceUsage {
3492        #[cfg(windows)]
3493        {
3494            let image = lock_snapshot(&self.inner.snapshot)
3495                .ok()
3496                .and_then(|snapshot| {
3497                    snapshot
3498                        .spawned_file_identity
3499                        .as_ref()
3500                        .and_then(|identity| identity.windows_image.clone())
3501                });
3502            match image {
3503                Some(image) => {
3504                    crate::child_resources::read_windows_handle(image.process(), image.start_time())
3505                }
3506                None => subc_control::ChildResourceUsage::Unavailable {
3507                    reason: subc_control::ChildResourceUnavailableReason::NotRunning,
3508                },
3509            }
3510        }
3511        #[cfg(not(windows))]
3512        {
3513            let (pid, start_time) = match lock_snapshot(&self.inner.snapshot) {
3514                Ok(snapshot) => (snapshot.reported_pid(), snapshot.process_start_time),
3515                Err(_) => {
3516                    return subc_control::ChildResourceUsage::Unavailable {
3517                        reason: subc_control::ChildResourceUnavailableReason::Unreadable,
3518                    }
3519                }
3520            };
3521            crate::child_resources::read(pid, start_time)
3522        }
3523    }
3524
3525    pub(crate) fn will_recover_after_connection_loss(&self) -> Result<bool, SuperviseError> {
3526        let mut snapshot = lock_snapshot(&self.inner.snapshot)?;
3527        Ok(match snapshot.state {
3528            ModuleState::Restarting => true,
3529            ModuleState::Failed | ModuleState::Disabled => false,
3530            _ => daemon_will_restart(&mut snapshot, &self.inner.restart_policy, Instant::now()),
3531        })
3532    }
3533
3534    #[cfg(test)]
3535    pub(crate) fn is_warming(&self) -> Result<bool, SuperviseError> {
3536        self.is_warming_with_snapshot_lock(None)
3537    }
3538
3539    pub(crate) fn is_warming_for_control(
3540        &self,
3541        caller: &'static str,
3542    ) -> Result<bool, SuperviseError> {
3543        self.is_warming_with_snapshot_lock(Some(caller))
3544    }
3545
3546    fn is_warming_with_snapshot_lock(
3547        &self,
3548        caller: Option<&'static str>,
3549    ) -> Result<bool, SuperviseError> {
3550        let snapshot = match caller {
3551            Some(caller) => {
3552                lock_snapshot_for_control(&self.inner.snapshot, &self.inner.module_id, caller)?
3553            }
3554            None => lock_snapshot(&self.inner.snapshot)?,
3555        }
3556        .clone();
3557        Ok(matches!(
3558            snapshot.state,
3559            ModuleState::Starting | ModuleState::Running | ModuleState::Restarting
3560        ))
3561    }
3562
3563    /// Drain the module and stop monitoring it.
3564    pub async fn drain(&self) -> Result<(), SuperviseError> {
3565        self.stop().await
3566    }
3567
3568    pub(crate) async fn retire(&self) -> Result<(), SuperviseError> {
3569        match self.state()? {
3570            ModuleState::Stopped | ModuleState::Failed => return Ok(()),
3571            ModuleState::Starting
3572            | ModuleState::Running
3573            | ModuleState::Unresponsive
3574            | ModuleState::Restarting
3575            | ModuleState::Draining
3576            | ModuleState::Disabled => {}
3577        }
3578
3579        let (reply_tx, reply_rx) = oneshot::channel();
3580        self.inner
3581            .commands
3582            .send(SupervisorCommand::Retire { reply: reply_tx })
3583            .await
3584            .map_err(|_| SuperviseError::CommandClosed {
3585                module_id: self.inner.module_id.clone(),
3586            })?;
3587        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3588            module_id: self.inner.module_id.clone(),
3589        })?
3590    }
3591
3592    pub async fn stop(&self) -> Result<(), SuperviseError> {
3593        match self.state()? {
3594            ModuleState::Stopped | ModuleState::Failed => return Ok(()),
3595            ModuleState::Starting
3596            | ModuleState::Running
3597            | ModuleState::Unresponsive
3598            | ModuleState::Restarting
3599            | ModuleState::Draining
3600            | ModuleState::Disabled => {}
3601        }
3602
3603        let (reply_tx, reply_rx) = oneshot::channel();
3604        self.inner
3605            .commands
3606            .send(SupervisorCommand::Drain { reply: reply_tx })
3607            .await
3608            .map_err(|_| SuperviseError::CommandClosed {
3609                module_id: self.inner.module_id.clone(),
3610            })?;
3611        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3612            module_id: self.inner.module_id.clone(),
3613        })?
3614    }
3615
3616    pub async fn restart(&self, drain_timeout_ms: Option<u64>) -> Result<(), SuperviseError> {
3617        let received_at_generation = lock_snapshot(&self.inner.snapshot)?.spawn_generation;
3618        let (reply_tx, reply_rx) = oneshot::channel();
3619        self.inner
3620            .commands
3621            .send(SupervisorCommand::Restart {
3622                drain_timeout_ms,
3623                received_at_generation,
3624                queued_at: Instant::now(),
3625                reply: reply_tx,
3626            })
3627            .await
3628            .map_err(|_| SuperviseError::CommandClosed {
3629                module_id: self.inner.module_id.clone(),
3630            })?;
3631        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3632            module_id: self.inner.module_id.clone(),
3633        })?
3634    }
3635
3636    /// Blue/green restart: see [`SupervisorCommand::Swap`] and the
3637    /// `supervisor_swap` module. Returns once the swap has cut over (the old
3638    /// process then drains in the background of the supervise loop) or has
3639    /// failed, leaving the old process serving.
3640    pub async fn swap(&self, ready_timeout: Option<Duration>) -> Result<(), SuperviseError> {
3641        let (reply_tx, reply_rx) = oneshot::channel();
3642        self.inner
3643            .commands
3644            .send(SupervisorCommand::Swap {
3645                ready_timeout,
3646                reply: reply_tx,
3647            })
3648            .await
3649            .map_err(|_| SuperviseError::CommandClosed {
3650                module_id: self.inner.module_id.clone(),
3651            })?;
3652        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3653            module_id: self.inner.module_id.clone(),
3654        })?
3655    }
3656
3657    pub async fn reload(&self) -> Result<(), SuperviseError> {
3658        let (reply_tx, reply_rx) = oneshot::channel();
3659        self.inner
3660            .commands
3661            .send(SupervisorCommand::Reload { reply: reply_tx })
3662            .await
3663            .map_err(|_| SuperviseError::CommandClosed {
3664                module_id: self.inner.module_id.clone(),
3665            })?;
3666        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3667            module_id: self.inner.module_id.clone(),
3668        })?
3669    }
3670
3671    pub async fn set_enabled(&self, enabled: bool) -> Result<bool, SuperviseError> {
3672        let (reply_tx, reply_rx) = oneshot::channel();
3673        self.inner
3674            .commands
3675            .send(SupervisorCommand::SetEnabled {
3676                enabled,
3677                reply: reply_tx,
3678            })
3679            .await
3680            .map_err(|_| SuperviseError::CommandClosed {
3681                module_id: self.inner.module_id.clone(),
3682            })?;
3683        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3684            module_id: self.inner.module_id.clone(),
3685        })?
3686    }
3687
3688    /// The current process's protocol, or the configured protocol when down.
3689    /// A rescan stores the next launch spec without changing how an existing
3690    /// process registers, serves routes, is probed, or exits.
3691    pub(crate) fn declared_protocol(&self) -> Result<ModuleProtocol, SuperviseError> {
3692        let configured = self
3693            .inner
3694            .configuration
3695            .lock()
3696            .map_err(|_| SuperviseError::StatePoisoned {
3697                module_id: Some(self.inner.module_id.clone()),
3698            })?
3699            .spec
3700            .protocol;
3701        let state = lock_snapshot(&self.inner.snapshot)?;
3702        Ok(state.spawned_protocol.unwrap_or(configured))
3703    }
3704
3705    pub(crate) fn configuration(&self) -> Result<(ModuleSpec, HealthConfig), SuperviseError> {
3706        let configuration =
3707            self.inner
3708                .configuration
3709                .lock()
3710                .map_err(|_| SuperviseError::StatePoisoned {
3711                    module_id: Some(self.inner.module_id.clone()),
3712                })?;
3713        Ok((configuration.spec.clone(), configuration.health.clone()))
3714    }
3715
3716    /// Replace this module's launch spec, keeping its health and drain policy,
3717    /// the way a rescan does for a changed config entry. The running process is
3718    /// untouched; the next spawn (a restart, or a swap's candidate) uses it.
3719    #[cfg(any(test, feature = "test-support"))]
3720    pub async fn update_spec_for_test(&self, spec: ModuleSpec) -> Result<(), SuperviseError> {
3721        let (_, health) = self.configuration()?;
3722        let drain_timeout_ms = u64::try_from(
3723            self.inner
3724                .effective_drain_timeout
3725                .lock()
3726                .unwrap_or_else(|poisoned| poisoned.into_inner())
3727                .as_millis(),
3728        )
3729        .ok();
3730        self.update_configuration(spec, health, drain_timeout_ms)
3731            .await
3732    }
3733
3734    pub(crate) async fn update_configuration(
3735        &self,
3736        spec: ModuleSpec,
3737        health: HealthConfig,
3738        drain_timeout_ms: Option<u64>,
3739    ) -> Result<(), SuperviseError> {
3740        if spec.module_id != self.inner.module_id {
3741            return Err(SuperviseError::InvalidSpec {
3742                reason: "a supervised module's module_id cannot be changed".to_string(),
3743            });
3744        }
3745        validate_spec(&spec)?;
3746        let (reply_tx, reply_rx) = oneshot::channel();
3747        self.inner
3748            .commands
3749            .send(SupervisorCommand::UpdateConfiguration {
3750                spec: spec.clone(),
3751                health: health.clone(),
3752                drain_timeout_ms,
3753                reply: reply_tx,
3754            })
3755            .await
3756            .map_err(|_| SuperviseError::CommandClosed {
3757                module_id: self.inner.module_id.clone(),
3758            })?;
3759        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3760            module_id: self.inner.module_id.clone(),
3761        })?;
3762        let mut configuration =
3763            self.inner
3764                .configuration
3765                .lock()
3766                .map_err(|_| SuperviseError::StatePoisoned {
3767                    module_id: Some(self.inner.module_id.clone()),
3768                })?;
3769        configuration.spec = spec;
3770        configuration.health = health;
3771        Ok(())
3772    }
3773}
3774
3775impl Drop for SupervisedModuleInner {
3776    fn drop(&mut self) {
3777        let Ok(mut monitor) = self.monitor.lock() else {
3778            return;
3779        };
3780        if let Some(monitor) = monitor.as_ref().filter(|monitor| !monitor.is_finished()) {
3781            let _ = update_snapshot(&self.snapshot, Some(&self.module_id), |state| {
3782                state.state = ModuleState::Stopped;
3783                clear_current_process_facts(state);
3784            });
3785            monitor.abort();
3786        }
3787        let _ = monitor.take();
3788    }
3789}
3790
3791#[derive(Debug)]
3792enum SupervisorCommand {
3793    Drain {
3794        reply: oneshot::Sender<Result<(), SuperviseError>>,
3795    },
3796    Retire {
3797        reply: oneshot::Sender<Result<(), SuperviseError>>,
3798    },
3799    Restart {
3800        /// Operator override for this one restart's drain budget, in ms. `None`
3801        /// uses the module's configured/default budget; `Some(0)` cuts
3802        /// immediately (wedge bounce: a stuck request never settles, so
3803        /// waiting only delays recovery).
3804        drain_timeout_ms: Option<u64>,
3805        /// The module's `spawn_generation` when the request was received, before
3806        /// it waited in the command queue. A queued restart whose module has
3807        /// since spawned a newer process is already satisfied (see the handler).
3808        received_at_generation: u64,
3809        /// When the request entered the command queue, so the handler can log
3810        /// how long it waited behind the loop's other work.
3811        queued_at: Instant,
3812        reply: oneshot::Sender<Result<(), SuperviseError>>,
3813    },
3814    Reload {
3815        reply: oneshot::Sender<Result<(), SuperviseError>>,
3816    },
3817    SetEnabled {
3818        enabled: bool,
3819        reply: oneshot::Sender<Result<bool, SuperviseError>>,
3820    },
3821    UpdateConfiguration {
3822        spec: ModuleSpec,
3823        health: HealthConfig,
3824        /// Per-module drain override from the new config; `None` re-resolves to
3825        /// the supervisor-wide default.
3826        drain_timeout_ms: Option<u64>,
3827        reply: oneshot::Sender<()>,
3828    },
3829    Swap {
3830        /// How long the candidate may take to register and declare itself
3831        /// ready. `None` uses [`DEFAULT_SWAP_READY_TIMEOUT`].
3832        ready_timeout: Option<Duration>,
3833        /// Answered at cutover or failure; the incumbent's drain follows.
3834        reply: oneshot::Sender<Result<(), SuperviseError>>,
3835    },
3836}
3837
3838#[derive(Debug)]
3839pub enum SuperviseError {
3840    InvalidSpec {
3841        reason: String,
3842    },
3843    Spawn {
3844        program: PathBuf,
3845        source: io::Error,
3846        cgroup_path: Option<PathBuf>,
3847    },
3848    Cgroup {
3849        module_id: String,
3850        source: io::Error,
3851    },
3852    /// CSPRNG failure generating a reserved module's launch nonce. Fail loud rather
3853    /// than spawn a reserved module without its identity binding.
3854    LaunchNonce {
3855        reason: String,
3856    },
3857    Wait {
3858        module_id: String,
3859        source: io::Error,
3860    },
3861    Kill {
3862        module_id: String,
3863        source: io::Error,
3864    },
3865    Forwarding(ForwardingError),
3866    Registry(RegistryError),
3867    ReloadUnavailable {
3868        module_id: String,
3869        reason: String,
3870    },
3871    /// An operator restart/reload was requested for a module that is currently
3872    /// disabled. Restart/reload cycle a *running* module; a disabled module must
3873    /// be explicitly re-enabled (set_enabled(true)) rather than silently started
3874    /// by a restart, so these commands are rejected instead of re-enabling it.
3875    Disabled {
3876        module_id: String,
3877    },
3878    ReloadFailed {
3879        module_id: String,
3880        reason: String,
3881    },
3882    RegistrationStillActive {
3883        module_id: String,
3884        waited: Duration,
3885    },
3886    StatePoisoned {
3887        module_id: Option<String>,
3888    },
3889    CommandClosed {
3890        module_id: String,
3891    },
3892    /// A restart or reload arrived while a swap's candidate was warming. The
3893    /// swap owns the module until it cuts over or fails; a stop or disable
3894    /// would have aborted it instead.
3895    SwapInProgress {
3896        module_id: String,
3897    },
3898    /// A swap was refused before anything was spawned.
3899    SwapRefused {
3900        module_id: String,
3901        reason: SwapRefusal,
3902    },
3903    /// A swap spawned a candidate and gave up on it. The candidate has been
3904    /// killed and its slot freed; the incumbent was left serving and was never
3905    /// drained, except in the one `CutoverLost` case described on that arm.
3906    SwapFailed {
3907        module_id: String,
3908        arm: SwapFailureArm,
3909        detail: String,
3910        /// How the candidate exited, when it exited on its own before the
3911        /// supervisor gave up on it.
3912        candidate_exit: Option<ExitReport>,
3913    },
3914}
3915
3916/// Why a swap was refused before a candidate was spawned.
3917#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3918pub enum SwapRefusal {
3919    /// The module's config does not declare `overlap: "safe"`.
3920    OverlapExclusive,
3921    /// The module is not registered, so there is no incumbent to keep serving
3922    /// and nothing a swap would improve on; a plain restart is the tool.
3923    NotRegistered,
3924    /// The module does not speak the subc wire, so a candidate could never
3925    /// register or declare itself ready.
3926    ProtocolNone,
3927    /// The supervisor lacks the forwarding table (to cut routes over) or the
3928    /// shared handle (to admit the candidate's HELLO) that a swap needs.
3929    NotConfigured,
3930    /// A swap is already open for this module.
3931    AlreadySwapping,
3932}
3933
3934impl SwapRefusal {
3935    pub fn as_str(self) -> &'static str {
3936        match self {
3937            Self::OverlapExclusive => "overlap_exclusive",
3938            Self::NotRegistered => "not_registered",
3939            Self::ProtocolNone => "protocol_none",
3940            Self::NotConfigured => "not_configured",
3941            Self::AlreadySwapping => "already_swapping",
3942        }
3943    }
3944}
3945
3946/// Which failure arm ended a swap. Every arm but one leaves the incumbent
3947/// serving and undrained; see `CutoverLost`.
3948#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3949pub enum SwapFailureArm {
3950    /// The candidate process could not be started.
3951    SpawnFailed,
3952    /// The candidate did not register within the readiness budget.
3953    NeverRegistered,
3954    /// The candidate registered but did not declare itself ready in time.
3955    NeverReady,
3956    /// The candidate exited before cutover.
3957    CandidateExited,
3958    /// The candidate declared itself ready but failed its health probe.
3959    CandidateUnhealthy,
3960    /// An operator stop, disable or retire arrived while the candidate warmed.
3961    /// The candidate was killed and the operator's command then carried out on
3962    /// the incumbent.
3963    Interrupted,
3964    /// The candidate's connection closed at the moment of cutover. If it
3965    /// closed before forwarding moved, the incumbent is untouched. If it closed
3966    /// between the forwarding and registry halves of cutover, forwarding can no
3967    /// longer route to the incumbent, so the module is restarted plainly.
3968    CutoverLost,
3969}
3970
3971impl SwapFailureArm {
3972    pub fn as_str(self) -> &'static str {
3973        match self {
3974            Self::SpawnFailed => "spawn_failed",
3975            Self::NeverRegistered => "never_registered",
3976            Self::NeverReady => "never_ready",
3977            Self::CandidateExited => "candidate_exited",
3978            Self::CandidateUnhealthy => "candidate_unhealthy",
3979            Self::Interrupted => "interrupted",
3980            Self::CutoverLost => "cutover_lost",
3981        }
3982    }
3983}
3984
3985impl fmt::Display for SuperviseError {
3986    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3987        match self {
3988            Self::InvalidSpec { reason } => write!(f, "invalid module spec: {reason}"),
3989            Self::Spawn {
3990                program,
3991                source,
3992                cgroup_path: Some(cgroup_path),
3993            } => write!(
3994                f,
3995                "failed to place module in cgroup '{}' while spawning '{}': {source}",
3996                cgroup_path.display(),
3997                program.display()
3998            ),
3999            Self::Spawn {
4000                program,
4001                source,
4002                cgroup_path: None,
4003            } => write!(
4004                f,
4005                "failed to spawn module '{}': {source}",
4006                program.display()
4007            ),
4008            Self::Cgroup { module_id, source } => {
4009                write!(
4010                    f,
4011                    "failed to prepare cgroup for module '{module_id}': {source}"
4012                )
4013            }
4014            Self::LaunchNonce { reason } => {
4015                write!(
4016                    f,
4017                    "failed to generate reserved-module launch nonce: {reason}"
4018                )
4019            }
4020            Self::Wait { module_id, source } => {
4021                write!(f, "failed to wait for module '{module_id}': {source}")
4022            }
4023            Self::Kill { module_id, source } => {
4024                write!(f, "failed to kill module '{module_id}': {source}")
4025            }
4026            Self::Forwarding(err) => write!(f, "forwarding error: {err}"),
4027            Self::Registry(err) => write!(f, "registry error: {err}"),
4028            Self::ReloadUnavailable { module_id, reason } => {
4029                write!(f, "reload unavailable for module '{module_id}': {reason}")
4030            }
4031            Self::Disabled { module_id } => {
4032                write!(
4033                    f,
4034                    "module '{module_id}' is disabled; enable it before restart or reload"
4035                )
4036            }
4037            Self::ReloadFailed { module_id, reason } => {
4038                write!(f, "reload failed for module '{module_id}': {reason}")
4039            }
4040            Self::RegistrationStillActive { module_id, waited } => write!(
4041                f,
4042                "module '{module_id}' registration remained active after waiting {waited:?}"
4043            ),
4044            Self::StatePoisoned { module_id } => match module_id {
4045                Some(module_id) => {
4046                    write!(f, "supervisor state for module '{module_id}' was poisoned")
4047                }
4048                None => write!(f, "supervisor state was poisoned"),
4049            },
4050            Self::CommandClosed { module_id } => {
4051                write!(
4052                    f,
4053                    "supervisor command channel for module '{module_id}' is closed"
4054                )
4055            }
4056            Self::SwapInProgress { module_id } => write!(
4057                f,
4058                "module '{module_id}' is being swapped; retry once the swap has cut over or failed, or stop the module to abort the swap"
4059            ),
4060            Self::SwapRefused { module_id, reason } => match reason {
4061                SwapRefusal::OverlapExclusive => write!(
4062                    f,
4063                    "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"
4064                ),
4065                SwapRefusal::NotRegistered => write!(
4066                    f,
4067                    "module '{module_id}' is not registered, so there is no serving process to keep while a replacement warms; use a plain restart"
4068                ),
4069                SwapRefusal::ProtocolNone => write!(
4070                    f,
4071                    "module '{module_id}' is protocol: \"none\" and never registers, so a swap could never see its replacement become ready; use a plain restart"
4072                ),
4073                SwapRefusal::NotConfigured => write!(
4074                    f,
4075                    "module '{module_id}' cannot be swapped: the supervisor was built without the forwarding table or shared handle a swap needs"
4076                ),
4077                SwapRefusal::AlreadySwapping => {
4078                    write!(f, "module '{module_id}' is already being swapped")
4079                }
4080            },
4081            Self::SwapFailed {
4082                module_id,
4083                arm,
4084                detail,
4085                ..
4086            } => write!(
4087                f,
4088                "swap of module '{module_id}' failed ({}): {detail}; the running process was left serving",
4089                arm.as_str()
4090            ),
4091        }
4092    }
4093}
4094
4095impl Error for SuperviseError {
4096    fn source(&self) -> Option<&(dyn Error + 'static)> {
4097        match self {
4098            Self::Spawn { source, .. }
4099            | Self::Cgroup { source, .. }
4100            | Self::Wait { source, .. }
4101            | Self::Kill { source, .. } => Some(source),
4102            Self::Forwarding(err) => Some(err),
4103            Self::Registry(err) => Some(err),
4104            Self::LaunchNonce { .. }
4105            | Self::InvalidSpec { .. }
4106            | Self::ReloadUnavailable { .. }
4107            | Self::Disabled { .. }
4108            | Self::ReloadFailed { .. }
4109            | Self::RegistrationStillActive { .. }
4110            | Self::StatePoisoned { .. }
4111            | Self::CommandClosed { .. }
4112            | Self::SwapInProgress { .. }
4113            | Self::SwapRefused { .. }
4114            | Self::SwapFailed { .. } => None,
4115        }
4116    }
4117}
4118
4119pub(crate) fn validate_spec(spec: &ModuleSpec) -> Result<(), SuperviseError> {
4120    if spec.module_id.trim().is_empty() {
4121        return Err(SuperviseError::InvalidSpec {
4122            reason: "module_id must not be empty".to_string(),
4123        });
4124    }
4125
4126    Ok(())
4127}
4128
4129#[derive(Debug, Default)]
4130struct HealthProbeRuntime {
4131    configured_health: Option<HealthConfig>,
4132    registered_connection: Option<crate::ConnectionId>,
4133    advertised: bool,
4134    next_probe_at: Option<Instant>,
4135    probe_index: u64,
4136}
4137
4138fn running_protocol(spec: &ModuleSpec, snapshot: &SharedSnapshot) -> ModuleProtocol {
4139    lock_snapshot(snapshot)
4140        .ok()
4141        .and_then(|state| state.spawned_protocol)
4142        .unwrap_or(spec.protocol)
4143}
4144
4145impl HealthProbeRuntime {
4146    fn refresh_registration(
4147        &mut self,
4148        spec: &ModuleSpec,
4149        runtime: &SupervisorRuntimeConfig,
4150        registry: &Registry,
4151        snapshot: &SharedSnapshot,
4152    ) {
4153        if self.configured_health.as_ref() != Some(&runtime.health) {
4154            self.configured_health = Some(runtime.health.clone());
4155            self.next_probe_at = None;
4156            self.registered_connection = None;
4157            self.probe_index = 0;
4158        }
4159        // A non-wire process never registers. Only an explicitly configured
4160        // HTTP endpoint can arm its health probe; an absent HELLO is not a
4161        // health failure for that kind of process.
4162        if running_protocol(spec, snapshot) == ModuleProtocol::None {
4163            self.registered_connection = None;
4164            self.advertised = runtime.health.http.is_some();
4165            if !self.advertised {
4166                self.next_probe_at = None;
4167                let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4168                    let unknown = ModuleHealthStatus::default();
4169                    if state.health != unknown {
4170                        state.health = unknown;
4171                    }
4172                });
4173            } else if self.next_probe_at.is_none() {
4174                self.next_probe_at = Some(
4175                    Instant::now()
4176                        + jittered_health_delay(&spec.module_id, 0, runtime.health.cadence),
4177                );
4178            }
4179            return;
4180        }
4181
4182        let registration = match registry.get_module(&spec.module_id) {
4183            Ok(registration) => registration,
4184            Err(err) => {
4185                warn!(module_id = %spec.module_id, error = %err, "health prober could not read registry");
4186                self.advertised = false;
4187                self.next_probe_at = None;
4188                return;
4189            }
4190        };
4191
4192        let Some(registration) = registration else {
4193            self.registered_connection = None;
4194            self.advertised = false;
4195            self.next_probe_at = None;
4196            return;
4197        };
4198
4199        let advertised = registration
4200            .control_ops
4201            .iter()
4202            .any(|op| op == MODULE_CONTROL_OP_HEALTH_CHECK);
4203        if !advertised {
4204            self.registered_connection = Some(registration.connection_id);
4205            self.advertised = false;
4206            self.next_probe_at = None;
4207            let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4208                if state.health.status != SupervisorHealthStatus::Unknown
4209                    || state.health.consecutive_failures != 0
4210                    || state.health.last_probe_ms.is_some()
4211                    || state.health.detail.is_some()
4212                    || state.health.metrics.is_some()
4213                {
4214                    state.health.status = SupervisorHealthStatus::Unknown;
4215                    state.health.consecutive_failures = 0;
4216                    state.health.last_probe_ms = None;
4217                    state.health.detail = None;
4218                    state.health.metrics = None;
4219                }
4220            });
4221            return;
4222        }
4223
4224        let reregistered = self.registered_connection != Some(registration.connection_id);
4225        self.registered_connection = Some(registration.connection_id);
4226        self.advertised = true;
4227        if reregistered || self.next_probe_at.is_none() {
4228            self.probe_index = 0;
4229            self.next_probe_at = Some(
4230                Instant::now() + jittered_health_delay(&spec.module_id, 0, runtime.health.cadence),
4231            );
4232            let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4233                state.health.status = SupervisorHealthStatus::Unknown;
4234                state.health.consecutive_failures = 0;
4235                state.health.detail = None;
4236                state.health.metrics = None;
4237            });
4238        }
4239    }
4240
4241    fn wake_after(&self) -> Option<Duration> {
4242        if !self.advertised {
4243            return None;
4244        }
4245        self.next_probe_at
4246            .map(|next| next.saturating_duration_since(Instant::now()))
4247    }
4248
4249    fn due(&self) -> bool {
4250        self.advertised
4251            && self
4252                .next_probe_at
4253                .is_some_and(|next| Instant::now() >= next)
4254    }
4255
4256    fn schedule_next(&mut self, spec: &ModuleSpec, cadence: Duration) {
4257        self.probe_index = self.probe_index.wrapping_add(1);
4258        self.next_probe_at = Some(
4259            Instant::now() + jittered_health_delay(&spec.module_id, self.probe_index, cadence),
4260        );
4261    }
4262}
4263
4264/// What a failed health probe actually OBSERVED, kept apart from how it reads.
4265///
4266/// This was a struct with a single `message: String`, and every one of the
4267/// fifteen construction sites collapsed into it. Each site knows exactly what it
4268/// saw -- the lane is gone, the module did not answer in time, the module
4269/// answered with the wrong thing -- and `handle_health_probe_failure` then
4270/// treated all of them identically: increment a counter, compare to a threshold,
4271/// restart the module. THE DISTINCTION EXISTED AT EVERY CALL SITE AND WAS
4272/// DESTROYED BEFORE THE DECISION THAT NEEDED IT.
4273///
4274/// The distinction that matters is not severity, it is EVIDENTIAL WEIGHT:
4275///
4276/// * `LaneDead` is PROOF. The module's control connection is gone; nothing will
4277///   answer on it again.
4278/// * `NoAnswer` is ABSENCE OF EVIDENCE. It is consistent with a wedged module
4279///   AND with a perfectly healthy one that lost a CPU race -- which is what
4280///   happens under machine load, and is how this supervisor killed a healthy
4281///   module three times in one day.
4282/// * `BadAnswer` proves the module is ALIVE. It replied; the reply was wrong.
4283///   Restarting on it is defensible, but it is not the silence case and should
4284///   never be counted as one.
4285/// * `Misconfigured` is a daemon-side fault. The module has not been asked
4286///   anything, so it cannot be evidence about the module at all.
4287///
4288/// The asymmetry is the whole point: under saturation the WEAKEST signal is the
4289/// one that fires most often, and while every variant collapsed into one string
4290/// it carried the same weight as the strongest.
4291///
4292/// LIVE BEHAVIOUR TODAY, stated here because this doc block describes the
4293/// DESIGN and a reader stopping at it gets the build backwards: the restart
4294/// decision does NOT yet consult this classification -- consecutive `NoAnswer`
4295/// probes still increment the failure streak and drive escalation at the
4296/// threshold (see `is_proof_of_death` below for why that is deliberate and
4297/// what gates the change). Absence of evidence restarts modules today.
4298#[derive(Debug)]
4299enum HealthProbeEvidence {
4300    /// The module's control lane is gone. Proof of death.
4301    LaneDead,
4302    /// No reply within the deadline. Proves nothing about the module's state.
4303    NoAnswer,
4304    /// The module replied, but not with a usable health report. Proves it is alive.
4305    BadAnswer,
4306    /// The daemon could not ask. Says nothing about the module.
4307    Misconfigured,
4308}
4309
4310#[derive(Debug)]
4311struct HealthProbeError {
4312    evidence: HealthProbeEvidence,
4313    message: String,
4314}
4315
4316impl HealthProbeError {
4317    fn lane_dead(message: impl Into<String>) -> Self {
4318        Self::with(HealthProbeEvidence::LaneDead, message)
4319    }
4320
4321    fn no_answer(message: impl Into<String>) -> Self {
4322        Self::with(HealthProbeEvidence::NoAnswer, message)
4323    }
4324
4325    fn bad_answer(message: impl Into<String>) -> Self {
4326        Self::with(HealthProbeEvidence::BadAnswer, message)
4327    }
4328
4329    fn misconfigured(message: impl Into<String>) -> Self {
4330        Self::with(HealthProbeEvidence::Misconfigured, message)
4331    }
4332
4333    fn with(evidence: HealthProbeEvidence, message: impl Into<String>) -> Self {
4334        Self {
4335            evidence,
4336            message: message.into(),
4337        }
4338    }
4339
4340    /// Whether this observation is proof the module cannot serve.
4341    ///
4342    /// Only `LaneDead` qualifies. `NoAnswer` is deliberately excluded: it is the
4343    /// variant that fires under CPU starvation, and treating it as proof is the
4344    /// defect this enum exists to make impossible to reintroduce silently.
4345    ///
4346    /// NOT YET CONSULTED BY THE RESTART DECISION, deliberately. Requiring proof
4347    /// to restart also needs a bound for the case it excludes -- a genuinely
4348    /// wedged module, alive but never answering -- and that bound must come from
4349    /// the distribution of real late-answer latencies, which nothing measures
4350    /// yet. Landing the classification first makes the later change a one-line
4351    /// decision against evidence that already exists, rather than two unproven
4352    /// changes at once.
4353    #[allow(dead_code)]
4354    fn is_proof_of_death(&self) -> bool {
4355        matches!(self.evidence, HealthProbeEvidence::LaneDead)
4356    }
4357
4358    /// Short stable label for logs and the health snapshot.
4359    ///
4360    /// An operator reading `ck health` currently cannot tell "the module is gone"
4361    /// from "the module did not answer in five seconds", because both render as
4362    /// prose in the same field. These labels are what make the two
4363    /// distinguishable at a glance, and they are what a later restart-policy
4364    /// change will be argued from.
4365    fn label(&self) -> &'static str {
4366        match self.evidence {
4367            HealthProbeEvidence::LaneDead => "lane-dead",
4368            HealthProbeEvidence::NoAnswer => "no-answer",
4369            HealthProbeEvidence::BadAnswer => "bad-answer",
4370            HealthProbeEvidence::Misconfigured => "daemon-misconfigured",
4371        }
4372    }
4373}
4374
4375impl fmt::Display for HealthProbeError {
4376    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4377        f.write_str(&self.message)
4378    }
4379}
4380
4381async fn run_health_probe_cycle(
4382    spec: &ModuleSpec,
4383    runtime: &SupervisorRuntimeConfig,
4384    registry: &Registry,
4385    process_liveness: &SupervisorProcessLiveness,
4386    snapshot: &SharedSnapshot,
4387    child: &mut Option<SupervisedChild>,
4388) {
4389    let now_ms = unix_ms_now();
4390    let http = (running_protocol(spec, snapshot) == ModuleProtocol::None)
4391        .then_some(runtime.health.http.as_deref())
4392        .flatten();
4393    let result = match http {
4394        Some(url) => probe_http_health(url, runtime.health.deadline).await,
4395        None => probe_module_health(&spec.module_id, runtime, None).await,
4396    };
4397    match result {
4398        Ok(report) => {
4399            handle_health_report(
4400                spec,
4401                runtime,
4402                registry,
4403                process_liveness,
4404                snapshot,
4405                child,
4406                report,
4407                now_ms,
4408            )
4409            .await;
4410        }
4411        Err(err) => {
4412            if http.is_some() {
4413                let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4414                    state.health.status = SupervisorHealthStatus::Failing;
4415                });
4416            }
4417            handle_health_probe_failure(
4418                spec,
4419                runtime,
4420                registry,
4421                process_liveness,
4422                snapshot,
4423                child,
4424                err,
4425                now_ms,
4426            )
4427            .await;
4428        }
4429    }
4430}
4431
4432pub(crate) struct HttpProbeTarget<'a> {
4433    address: std::net::SocketAddr,
4434    localhost: bool,
4435    authority: &'a str,
4436    path: String,
4437}
4438
4439/// Resolve only literal loopback endpoints, without DNS, redirects, proxies,
4440/// or TLS. A URL cannot turn a local health check into an outbound connection.
4441pub(crate) fn parse_http_probe_url(url: &str) -> Result<HttpProbeTarget<'_>, String> {
4442    if url.bytes().any(|b| b <= b' ' || b == 127) || url.contains('#') {
4443        return Err("must not contain whitespace, controls, or a fragment".into());
4444    }
4445    let rest = url
4446        .strip_prefix("http://")
4447        .ok_or("must use plain http://")?;
4448    let split = rest.find(['/', '?']).unwrap_or(rest.len());
4449    let (authority, suffix) = rest.split_at(split);
4450    let (host, port) = if let Some(rest) = authority.strip_prefix("[::1]") {
4451        ("::1", rest)
4452    } else {
4453        let split = authority.find(':').unwrap_or(authority.len());
4454        authority.split_at(split)
4455    };
4456    let ip: std::net::IpAddr = match host {
4457        "127.0.0.1" | "localhost" => std::net::Ipv4Addr::LOCALHOST.into(),
4458        "::1" => std::net::Ipv6Addr::LOCALHOST.into(),
4459        _ => return Err("host must be 127.0.0.1, [::1], or localhost".into()),
4460    };
4461    let port = if port.is_empty() {
4462        80
4463    } else {
4464        port.strip_prefix(':')
4465            .and_then(|p| p.parse::<u16>().ok())
4466            .filter(|p| *p > 0)
4467            .ok_or("must have a valid nonzero TCP port")?
4468    };
4469    let path = if suffix.is_empty() {
4470        "/".into()
4471    } else if suffix.starts_with('?') {
4472        format!("/{suffix}")
4473    } else {
4474        suffix.into()
4475    };
4476    Ok(HttpProbeTarget {
4477        address: std::net::SocketAddr::new(ip, port),
4478        localhost: host == "localhost",
4479        authority,
4480        path,
4481    })
4482}
4483
4484async fn probe_http_health(
4485    url: &str,
4486    deadline: Duration,
4487) -> Result<HealthReport, HealthProbeError> {
4488    use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader};
4489    let target = parse_http_probe_url(url).map_err(HealthProbeError::misconfigured)?;
4490    // Keep partial diagnostics outside the timed future so cancellation does
4491    // not discard a status line or body bytes already received.
4492    let mut response_status = String::new();
4493    let mut body = Vec::new();
4494    let probe = async {
4495        // Resolve localhost ourselves so a hosts-file override cannot turn
4496        // this into an outbound request, while IPv6-only local servers work.
4497        let connection = match tokio::net::TcpStream::connect(target.address).await {
4498            Err(_) if target.localhost => {
4499                tokio::net::TcpStream::connect((
4500                    std::net::Ipv6Addr::LOCALHOST,
4501                    target.address.port(),
4502                ))
4503                .await
4504            }
4505            result => result,
4506        };
4507        let mut stream = connection.map_err(|error| {
4508            HealthProbeError::no_answer(format!("HTTP connect failed: {error}"))
4509        })?;
4510        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
4511            .map_err(|error| HealthProbeError::no_answer(format!("HTTP write failed: {error}")))?;
4512        let mut reader = BufReader::new(stream);
4513        let mut budget = 16 * 1024;
4514        let status = http_line(&mut reader, &mut budget).await?;
4515        let mut words = status.split_ascii_whitespace();
4516        let version = words.next();
4517        let code = words
4518            .next()
4519            .filter(|word| word.len() == 3)
4520            .and_then(|word| word.parse::<u16>().ok());
4521        if !matches!(version, Some("HTTP/1.1" | "HTTP/1.0"))
4522            || !code.is_some_and(|code| (100..600).contains(&code))
4523        {
4524            return Err(HealthProbeError::bad_answer(format!(
4525                "invalid HTTP status: {status}"
4526            )));
4527        }
4528        let code = code.expect("validated status code");
4529        response_status = status.clone();
4530        let mut length = None;
4531        let mut chunked = false;
4532        loop {
4533            let line = http_line(&mut reader, &mut budget).await?;
4534            if line.is_empty() {
4535                break;
4536            }
4537            if let Some((name, value)) = line.split_once(':') {
4538                if name.eq_ignore_ascii_case("content-length") {
4539                    length = Some(value.trim().parse::<u64>().map_err(|_| {
4540                        HealthProbeError::bad_answer("invalid HTTP Content-Length")
4541                    })?);
4542                } else if name.eq_ignore_ascii_case("transfer-encoding") {
4543                    chunked = value.trim().eq_ignore_ascii_case("chunked");
4544                }
4545            }
4546        }
4547        if chunked {
4548            while body.len() < 200 {
4549                let line = http_line(&mut reader, &mut budget).await?;
4550                let size = u64::from_str_radix(line.split(';').next().unwrap_or("").trim(), 16)
4551                    .map_err(|_| HealthProbeError::bad_answer("invalid HTTP chunk size"))?;
4552                if size == 0 {
4553                    break;
4554                }
4555                let count = size.min((200 - body.len()) as u64) as usize;
4556                let start = body.len();
4557                (&mut reader)
4558                    .take(count as u64)
4559                    .read_to_end(&mut body)
4560                    .await
4561                    .map_err(|error| {
4562                        HealthProbeError::no_answer(format!("HTTP body read failed: {error}"))
4563                    })?;
4564                if body.len() - start != count {
4565                    return Err(HealthProbeError::bad_answer("truncated HTTP chunk"));
4566                }
4567                if size > count as u64 || body.len() == 200 {
4568                    break;
4569                }
4570                if !http_line(&mut reader, &mut budget).await?.is_empty() {
4571                    return Err(HealthProbeError::bad_answer("invalid HTTP chunk delimiter"));
4572                }
4573            }
4574        } else {
4575            reader
4576                .take(length.unwrap_or(200).min(200))
4577                .read_to_end(&mut body)
4578                .await
4579                .map_err(|error| {
4580                    HealthProbeError::no_answer(format!("HTTP body read failed: {error}"))
4581                })?;
4582        }
4583        if (200..300).contains(&code) {
4584            Ok(HealthReport::ok())
4585        } else {
4586            Err(HealthProbeError::bad_answer(
4587                "HTTP health endpoint returned non-2xx",
4588            ))
4589        }
4590    };
4591    let mut result = timeout(deadline, probe).await.unwrap_or_else(|_| {
4592        Err(HealthProbeError::no_answer(format!(
4593            "HTTP probe timed out after {deadline:?}"
4594        )))
4595    });
4596    if let Err(error) = &mut result {
4597        if !response_status.is_empty() {
4598            error.message = format!(
4599                "{}; {response_status}: {}",
4600                error.message,
4601                String::from_utf8_lossy(&body)
4602            );
4603        }
4604    }
4605    result
4606}
4607
4608async fn http_line(
4609    reader: &mut tokio::io::BufReader<tokio::net::TcpStream>,
4610    remaining: &mut usize,
4611) -> Result<String, HealthProbeError> {
4612    use tokio::io::{AsyncBufReadExt, AsyncReadExt};
4613    let mut line = Vec::new();
4614    (&mut *reader)
4615        .take(*remaining as u64)
4616        .read_until(b'\n', &mut line)
4617        .await
4618        .map_err(|error| {
4619            HealthProbeError::no_answer(format!("HTTP header read failed: {error}"))
4620        })?;
4621    *remaining -= line.len();
4622    if !line.ends_with(b"\r\n") {
4623        return Err(HealthProbeError::bad_answer(
4624            "HTTP headers are incomplete or exceed 16 KiB",
4625        ));
4626    }
4627    line.truncate(line.len() - 2);
4628    String::from_utf8(line).map_err(|_| HealthProbeError::bad_answer("HTTP header is not UTF-8"))
4629}
4630
4631async fn probe_module_health(
4632    module_id: &str,
4633    runtime: &SupervisorRuntimeConfig,
4634    drain_deadline: Option<Instant>,
4635) -> Result<HealthReport, HealthProbeError> {
4636    let Some(forwarding) = runtime.forwarding.as_ref() else {
4637        return Err(HealthProbeError::misconfigured(
4638            "supervisor was not configured with a forwarding table",
4639        ));
4640    };
4641    let probe_started_at = Instant::now();
4642    let mut deadline = probe_started_at + runtime.health.deadline;
4643    if let Some(drain_deadline) = drain_deadline {
4644        deadline = deadline.min(drain_deadline);
4645    }
4646    let pending = if drain_deadline.is_some() {
4647        forwarding.begin_drain_health_probe_rpc_for(
4648            module_id,
4649            MODULE_CONTROL_OP_HEALTH_CHECK,
4650            probe_started_at,
4651            deadline,
4652        )
4653    } else {
4654        forwarding.begin_health_probe_rpc_for(
4655            module_id,
4656            MODULE_CONTROL_OP_HEALTH_CHECK,
4657            probe_started_at,
4658            deadline,
4659        )
4660    }
4661    .map_err(|err| {
4662        // The endpoint is not registered, so there is no live control lane to
4663        // ask. That is the module being absent, not slow.
4664        HealthProbeError::lane_dead(format!("failed to begin health.check RPC: {err}"))
4665    })?;
4666    await_health_probe(forwarding, pending, deadline, runtime.health.deadline).await
4667}
4668
4669/// [`probe_module_health`] for one endpoint rather than the id's active one.
4670///
4671/// A swap probes two processes that no by-id lookup reaches: its candidate
4672/// before cutover, and its superseded incumbent (for busy gauges) while the
4673/// incumbent drains. `deadline_cap` bounds the probe the way a drain deadline
4674/// bounds the by-id drain probe.
4675async fn probe_endpoint_health(
4676    endpoint: crate::ModuleEndpointId,
4677    runtime: &SupervisorRuntimeConfig,
4678    deadline_cap: Option<Instant>,
4679) -> Result<HealthReport, HealthProbeError> {
4680    let Some(forwarding) = runtime.forwarding.as_ref() else {
4681        return Err(HealthProbeError::misconfigured(
4682            "supervisor was not configured with a forwarding table",
4683        ));
4684    };
4685    let probe_started_at = Instant::now();
4686    let mut deadline = probe_started_at + runtime.health.deadline;
4687    if let Some(cap) = deadline_cap {
4688        deadline = deadline.min(cap);
4689    }
4690    let pending = forwarding
4691        .begin_endpoint_health_probe_rpc_for(
4692            endpoint,
4693            MODULE_CONTROL_OP_HEALTH_CHECK,
4694            probe_started_at,
4695            deadline,
4696        )
4697        .map_err(|err| {
4698            HealthProbeError::lane_dead(format!("failed to begin health.check RPC: {err}"))
4699        })?;
4700    await_health_probe(forwarding, pending, deadline, runtime.health.deadline).await
4701}
4702
4703/// Send a begun health probe and classify its answer.
4704async fn await_health_probe(
4705    forwarding: &ForwardingTable,
4706    pending: PendingModuleControlRpc,
4707    deadline: Instant,
4708    probe_budget: Duration,
4709) -> Result<HealthReport, HealthProbeError> {
4710    let PendingModuleControlRpc {
4711        endpoint,
4712        module_sink,
4713        negotiated_ver,
4714        corr,
4715        receiver,
4716    } = pending;
4717    let body = serde_json::to_vec(&ModuleControlRequest::HealthCheck {}).map_err(|err| {
4718        HealthProbeError::misconfigured(format!("failed to encode health.check: {err}"))
4719    })?;
4720    let frame = Frame::build_with_version(
4721        negotiated_ver,
4722        FrameType::Request,
4723        control_flags(),
4724        0,
4725        0,
4726        corr,
4727        body,
4728    )
4729    .map_err(|err| {
4730        HealthProbeError::misconfigured(format!("failed to build health.check frame: {err}"))
4731    })?;
4732
4733    // The enqueue itself must be bounded by the probe deadline: FrameSink.send
4734    // blocks waiting for capacity when the module's egress queue is full, and an
4735    // unbounded await here freezes the whole supervision actor (it stops polling
4736    // Child::wait and supervisor commands), making the module unrecoverable
4737    // in-band. On timeout the probe fails like any transport failure.
4738    match timeout_at(deadline, module_sink.send(frame)).await {
4739        Ok(Ok(())) => {}
4740        Ok(Err(err)) => {
4741            let _ = forwarding.cancel_module_control_rpc(endpoint, corr);
4742            // A closed sink means the module's egress channel is gone -- the
4743            // receiving half is dropped when its connection tears down. Proof.
4744            return Err(HealthProbeError::lane_dead(format!(
4745                "failed to send health.check: {err}"
4746            )));
4747        }
4748        Err(_elapsed) => {
4749            let _ = forwarding.cancel_module_control_rpc(endpoint, corr);
4750            // A full egress queue means the module is not draining its socket, which
4751            // is consistent with a wedged module AND with one whose reader is merely
4752            // starved. Silence, not proof.
4753            return Err(HealthProbeError::no_answer(
4754                "health.check send timed out before enqueue (module egress full)",
4755            ));
4756        }
4757    }
4758
4759    match timeout_at(deadline, receiver).await {
4760        // Each arm records WHAT WAS OBSERVED. Four of them are the module
4761        // demonstrably answering -- rejected, non-health, malformed, wrong op --
4762        // and those prove it is alive even though the probe failed.
4763        Ok(Ok(ModuleControlRpcOutcome::Response(response))) => {
4764            response.health_report().ok_or_else(|| {
4765                HealthProbeError::bad_answer("health.check RPC returned a non-health response")
4766            })
4767        }
4768        Ok(Ok(ModuleControlRpcOutcome::Rejected(body))) => Err(HealthProbeError::bad_answer(
4769            format!("health.check rejected: {}", body.message),
4770        )),
4771        Ok(Ok(ModuleControlRpcOutcome::ModuleGone(message))) => {
4772            Err(HealthProbeError::lane_dead(message))
4773        }
4774        Ok(Ok(ModuleControlRpcOutcome::MalformedResponse(message))) => {
4775            Err(HealthProbeError::bad_answer(message))
4776        }
4777        Ok(Ok(ModuleControlRpcOutcome::UnexpectedOp { expected, actual })) => {
4778            Err(HealthProbeError::bad_answer(format!(
4779                "expected module-control op '{expected}', got '{actual}'"
4780            )))
4781        }
4782        // A reply that crosses the deadline before this waiter observes it is
4783        // still proof of life. The forwarding path records its end-to-end latency
4784        // before delivering this classification.
4785        Ok(Ok(ModuleControlRpcOutcome::DeadlineElapsed)) => Err(HealthProbeError::bad_answer(
4786            "module answered health.check after its daemon deadline",
4787        )),
4788        Ok(Err(_)) => Err(HealthProbeError::misconfigured(
4789            "health.check waiter was canceled before the module responded",
4790        )),
4791        Err(_) => {
4792            let _ = forwarding.tombstone_health_probe_rpc(endpoint, corr);
4793            Err(HealthProbeError::no_answer(format!(
4794                "module did not answer health.check within {probe_budget:?}"
4795            )))
4796        }
4797    }
4798}
4799
4800#[allow(clippy::too_many_arguments)]
4801async fn handle_health_report(
4802    spec: &ModuleSpec,
4803    runtime: &SupervisorRuntimeConfig,
4804    registry: &Registry,
4805    process_liveness: &SupervisorProcessLiveness,
4806    snapshot: &SharedSnapshot,
4807    child: &mut Option<SupervisedChild>,
4808    report: HealthReport,
4809    now_ms: u64,
4810) {
4811    let status = supervisor_health_status(report.status);
4812    let detail = report.detail.clone();
4813    let metrics = truncate_health_metrics(report.metrics);
4814    let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4815        state.health.status = status;
4816        state.health.last_probe_ms = Some(now_ms);
4817        state.health.detail = detail.clone();
4818        state.health.metrics = metrics.clone();
4819        state.health.consecutive_failures = 0;
4820    });
4821
4822    let action = match report.status {
4823        HealthStatus::Ok => return,
4824        HealthStatus::Degraded => runtime.health.on_degraded,
4825        HealthStatus::Failing => runtime.health.on_failing,
4826    };
4827    apply_l3_health_action(
4828        spec,
4829        runtime,
4830        registry,
4831        process_liveness,
4832        snapshot,
4833        child,
4834        status,
4835        detail.as_deref(),
4836        action,
4837        now_ms,
4838    )
4839    .await;
4840}
4841
4842#[allow(clippy::too_many_arguments)]
4843async fn handle_health_probe_failure(
4844    spec: &ModuleSpec,
4845    runtime: &SupervisorRuntimeConfig,
4846    registry: &Registry,
4847    process_liveness: &SupervisorProcessLiveness,
4848    snapshot: &SharedSnapshot,
4849    child: &mut Option<SupervisedChild>,
4850    err: HealthProbeError,
4851    now_ms: u64,
4852) {
4853    let threshold = runtime.health.failure_threshold.max(1);
4854    let mut failures = 0;
4855    // Carry the evidence class into the operator-visible detail. Without it,
4856    // "module did not answer within 5s" and "the control lane is gone" are two
4857    // prose strings in the same field, and the reader has to know the codebase to
4858    // tell which one is proof of anything.
4859    let detail = format!("[{}] {err}", err.label());
4860    let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4861        // A failed wire probe invalidates the last report, even before the
4862        // restart threshold. HTTP probes already mark failures as Failing.
4863        if state.spawned_protocol.unwrap_or(spec.protocol) == ModuleProtocol::Subc {
4864            state.health.status = SupervisorHealthStatus::Unknown;
4865        }
4866        state.health.last_probe_ms = Some(now_ms);
4867        state.health.consecutive_failures = state.health.consecutive_failures.saturating_add(1);
4868        state.health.detail = Some(detail.clone());
4869        state.health.metrics = None;
4870        failures = state.health.consecutive_failures;
4871    });
4872
4873    if failures < threshold {
4874        warn!(
4875            module_id = %spec.module_id,
4876            consecutive_failures = failures,
4877            threshold,
4878            evidence = err.label(),
4879            detail = %detail,
4880            "health.check probe failed"
4881        );
4882        return;
4883    }
4884
4885    let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4886        state.state = ModuleState::Unresponsive;
4887        state.health.status = SupervisorHealthStatus::Unresponsive;
4888    });
4889    // The evidence class is logged at the kill site because this is the line an
4890    // operator reads after an unexplained restart. A streak of `no-answer` under
4891    // machine load is the known false-positive shape; a `lane-dead` is not.
4892    if runtime.health.critical {
4893        error!(
4894            module_id = %spec.module_id,
4895            status = "unresponsive",
4896            evidence = err.label(),
4897            detail = %detail,
4898            "critical module health alert"
4899        );
4900    } else {
4901        warn!(
4902            module_id = %spec.module_id,
4903            status = "unresponsive",
4904            evidence = err.label(),
4905            detail = %detail,
4906            "module health threshold breached"
4907        );
4908    }
4909    if let Err(err) = health_restart_child(
4910        spec,
4911        runtime,
4912        registry,
4913        process_liveness,
4914        snapshot,
4915        child,
4916        SupervisorHealthStatus::Unresponsive,
4917        Some(&detail),
4918        now_ms,
4919    )
4920    .await
4921    {
4922        error!(module_id = %spec.module_id, error = %err, "health-triggered restart failed");
4923    }
4924}
4925
4926#[allow(clippy::too_many_arguments)]
4927async fn apply_l3_health_action(
4928    spec: &ModuleSpec,
4929    runtime: &SupervisorRuntimeConfig,
4930    registry: &Registry,
4931    process_liveness: &SupervisorProcessLiveness,
4932    snapshot: &SharedSnapshot,
4933    child: &mut Option<SupervisedChild>,
4934    status: SupervisorHealthStatus,
4935    detail: Option<&str>,
4936    action: HealthAction,
4937    now_ms: u64,
4938) {
4939    record_health_action(snapshot, &spec.module_id, action.to_string(), now_ms);
4940    match action {
4941        HealthAction::Report => {
4942            info!(
4943                module_id = %spec.module_id,
4944                status = ?status,
4945                detail,
4946                "module reported non-ok health"
4947            );
4948        }
4949        HealthAction::Alert => {
4950            error!(
4951                module_id = %spec.module_id,
4952                status = ?status,
4953                detail,
4954                "module health alert"
4955            );
4956        }
4957        HealthAction::Restart => {
4958            if let Err(err) = health_restart_child(
4959                spec,
4960                runtime,
4961                registry,
4962                process_liveness,
4963                snapshot,
4964                child,
4965                status,
4966                detail,
4967                now_ms,
4968            )
4969            .await
4970            {
4971                error!(module_id = %spec.module_id, error = %err, "health-triggered restart failed");
4972            }
4973        }
4974    }
4975}
4976
4977#[allow(clippy::too_many_arguments)]
4978async fn health_restart_child(
4979    spec: &ModuleSpec,
4980    runtime: &SupervisorRuntimeConfig,
4981    registry: &Registry,
4982    process_liveness: &SupervisorProcessLiveness,
4983    snapshot: &SharedSnapshot,
4984    child: &mut Option<SupervisedChild>,
4985    status: SupervisorHealthStatus,
4986    detail: Option<&str>,
4987    now_ms: u64,
4988) -> Result<(), SuperviseError> {
4989    let (enabled, schedule) = {
4990        let mut state = lock_snapshot(snapshot)?;
4991        let enabled = state.enabled;
4992        let schedule = if enabled {
4993            state.next_crash_restart(&runtime.restart_policy, Instant::now())
4994        } else {
4995            None
4996        };
4997        (enabled, schedule)
4998    };
4999
5000    if !enabled {
5001        return Err(SuperviseError::Disabled {
5002            module_id: spec.module_id.clone(),
5003        });
5004    }
5005
5006    if schedule.is_none() {
5007        record_health_action(snapshot, &spec.module_id, "failed".to_string(), now_ms);
5008        error!(
5009            module_id = %spec.module_id,
5010            status = ?status,
5011            detail,
5012            max_restarts = runtime.restart_policy.max_restarts,
5013            window_secs = runtime.restart_policy.window.as_secs(),
5014            reason = %runtime.restart_policy.budget_exhausted_detail(),
5015            "health restart budget exhausted; marking module failed"
5016        );
5017        let stop_notice = begin_forwarding_drain_if_configured(
5018            spec,
5019            runtime,
5020            registry,
5021            snapshot,
5022            Some(true),
5023            RouteCloseReason::Disable,
5024        )
5025        .await?;
5026        update_snapshot(snapshot, Some(&spec.module_id), |state| {
5027            state.drain_disposition_detail = Some(runtime.restart_policy.budget_exhausted_detail());
5028        })?;
5029        drain_optional_child(
5030            &spec.module_id,
5031            spec.protocol,
5032            stop_notice,
5033            registry,
5034            runtime.forwarding.as_deref(),
5035            snapshot,
5036            &runtime.terminal_ring,
5037            &runtime.spawn_events,
5038            child,
5039            runtime.drain_timeout,
5040            ModuleState::Failed,
5041            Some(true),
5042        )
5043        .await?;
5044        process_liveness.untrack_if_current(&spec.module_id, snapshot);
5045        return Ok(());
5046    }
5047
5048    let schedule = schedule.expect("a health restart must have a crash-restart schedule");
5049    let mut restart_count = 0;
5050    update_snapshot(snapshot, Some(&spec.module_id), |state| {
5051        restart_count = state.crash_restarts.len();
5052        state.state = ModuleState::Unresponsive;
5053        state.health.status = status;
5054        state.health.last_action = Some(HealthAction::Restart.to_string());
5055        state.health.last_action_ms = Some(now_ms);
5056    })?;
5057    warn!(
5058        module_id = %spec.module_id,
5059        status = ?status,
5060        detail,
5061        restart_count,
5062        restart_in_window = schedule.restart_in_window,
5063        delay_ms = schedule.delay.as_millis() as u64,
5064        "health-triggered module restart"
5065    );
5066
5067    let stop_notice = begin_forwarding_drain_if_configured(
5068        spec,
5069        runtime,
5070        registry,
5071        snapshot,
5072        Some(true),
5073        RouteCloseReason::Restart,
5074    )
5075    .await?;
5076    drain_optional_child(
5077        &spec.module_id,
5078        spec.protocol,
5079        stop_notice,
5080        registry,
5081        runtime.forwarding.as_deref(),
5082        snapshot,
5083        &runtime.terminal_ring,
5084        &runtime.spawn_events,
5085        child,
5086        runtime.drain_timeout,
5087        ModuleState::Restarting,
5088        Some(true),
5089    )
5090    .await?;
5091    schedule_respawn(
5092        runtime,
5093        snapshot,
5094        &spec.module_id,
5095        schedule.delay,
5096        RespawnKind::Spawn,
5097    )
5098}
5099
5100fn cancel_deferred_reload(runtime: &SupervisorRuntimeConfig, module_id: &str, reason: &str) {
5101    if let Some(reply) = runtime
5102        .deferred_reload_reply
5103        .lock()
5104        .unwrap_or_else(|p| p.into_inner())
5105        .take()
5106    {
5107        let _ = reply.send(Err(SuperviseError::ReloadFailed {
5108            module_id: module_id.to_string(),
5109            reason: reason.to_string(),
5110        }));
5111    }
5112}
5113
5114fn schedule_respawn(
5115    runtime: &SupervisorRuntimeConfig,
5116    snapshot: &SharedSnapshot,
5117    module_id: &str,
5118    delay: Duration,
5119    kind: RespawnKind,
5120) -> Result<(), SuperviseError> {
5121    cancel_deferred_reload(runtime, module_id, "respawn superseded by another restart");
5122    update_snapshot(snapshot, Some(module_id), |state| {
5123        state.respawn_pending = true
5124    })?;
5125    *runtime
5126        .scheduled_respawn
5127        .lock()
5128        .unwrap_or_else(|p| p.into_inner()) = Some(PendingRespawn {
5129        deadline: Instant::now() + delay,
5130        kind,
5131    });
5132    Ok(())
5133}
5134
5135fn record_health_action(snapshot: &SharedSnapshot, module_id: &str, action: String, now_ms: u64) {
5136    let _ = update_snapshot(snapshot, Some(module_id), |state| {
5137        state.health.last_action = Some(action);
5138        state.health.last_action_ms = Some(now_ms);
5139    });
5140}
5141
5142fn supervisor_health_status(status: HealthStatus) -> SupervisorHealthStatus {
5143    match status {
5144        HealthStatus::Ok => SupervisorHealthStatus::Ok,
5145        HealthStatus::Degraded => SupervisorHealthStatus::Degraded,
5146        HealthStatus::Failing => SupervisorHealthStatus::Failing,
5147    }
5148}
5149
5150/// Caps the metrics blob stored in the cached supervisor snapshot, which is
5151/// returned to every `supervisor.list` and `supervisor.health` caller.
5152///
5153/// This cap is deliberately NOT applied on the one-shot `supervisor.health_probe`
5154/// path: that request exists to return a module's complete metrics object, and
5155/// `ck health <module-id>` documents it as the way to see what the cached view
5156/// truncates. The asymmetry is the feature.
5157///
5158/// So a new caller must decide which side it is on rather than assume the cap is
5159/// universal. Reaching for it on a fresh-probe path would silently reintroduce
5160/// the truncation that path exists to avoid.
5161fn truncate_health_metrics(metrics: Option<Value>) -> Option<Value> {
5162    let metrics = metrics?;
5163    match serde_json::to_vec(&metrics) {
5164        Ok(encoded) if encoded.len() > MAX_HEALTH_METRICS_BYTES => Some(serde_json::json!({
5165            "truncated": true,
5166            "original_bytes": encoded.len(),
5167        })),
5168        Ok(_) | Err(_) => Some(metrics),
5169    }
5170}
5171
5172/// Spread health probes so a fleet-wide restart does not converge them.
5173///
5174/// The delay is derived from the module id and probe index rather than a random
5175/// source, so it is deterministic per module: a module keeps its own offset
5176/// across daemon restarts instead of re-rolling into a collision.
5177fn jittered_health_delay(module_id: &str, probe_index: u64, cadence: Duration) -> Duration {
5178    if cadence.is_zero() {
5179        return Duration::ZERO;
5180    }
5181    let cadence_ms = cadence.as_millis() as u64;
5182    // This early return is REDUNDANT, deliberately, and a mutation run will show
5183    // it surviving removal. Recording why here so the next person to notice does
5184    // not have to re-derive it:
5185    //
5186    // - It is unreachable in practice. `positive_millis` in daemon_config rejects
5187    //   a zero cadence and builds the Duration from whole milliseconds, so a
5188    //   sub-millisecond cadence cannot come from config.
5189    // - Even if reached it changes no answer. The `.max(1)` below makes the span
5190    //   1, and `hash % 1` is 0, so the fall-through returns `cadence` unchanged
5191    //   -- exactly what this returns.
5192    //
5193    // Kept as a guard against a future widening of the config parser (accepting
5194    // microseconds, say), which would make the sub-millisecond case reachable.
5195    // The `.max(1)` is the load-bearing half TODAY: remove it and the modulo
5196    // divides by zero. Remove this and nothing changes.
5197    if cadence_ms == 0 {
5198        return cadence;
5199    }
5200    // Note that this never returns less than one cadence, including for the FIRST
5201    // probe. So a freshly registered module reports health `unknown` for a full
5202    // cadence plus jitter -- 30-33s at the default -- no matter how quickly it is
5203    // ready to answer.
5204    //
5205    // That is a property of the supervisor's schedule, not of any module: an
5206    // operator watching a restart sees `unknown` and cannot tell it from a module
5207    // that is slow to warm. Measured on two unrelated modules, both flipping to
5208    // `ok` between 22s and 32s after restart.
5209    //
5210    // Left as-is because spreading the first probe is what keeps a fleet-wide
5211    // restart from firing fourteen simultaneous probes into a cold machine. The
5212    // alternative -- probe at t+0 and jitter only from the second onward -- trades
5213    // that thundering herd for a faster first reading.
5214    let jitter_span = (cadence_ms / 10).max(1);
5215    let hash = module_id.as_bytes().iter().fold(
5216        probe_index.wrapping_mul(0x9E37_79B9_7F4A_7C15),
5217        |acc, byte| {
5218            acc.wrapping_mul(1099511628211)
5219                .wrapping_add(u64::from(*byte))
5220        },
5221    );
5222    cadence + Duration::from_millis(hash % jitter_span)
5223}
5224
5225#[cfg(test)]
5226mod tests {
5227    use super::*;
5228
5229    #[test]
5230    fn readding_a_module_clears_its_rescan_removal_tombstone() {
5231        let handle = SupervisorHandle::new();
5232        let module_id = "readded-tombstone";
5233        handle.record_rescan_removal(module_id);
5234        assert!(handle.removal_tombstone_age_ms(module_id).is_some());
5235
5236        handle.apply_identity_configuration(&ModuleSpec {
5237            module_id: module_id.to_string(),
5238            program: PathBuf::from("/test/module"),
5239            args: Vec::new(),
5240            env: Vec::new(),
5241            reserved: false,
5242            reserved_prefixes: Vec::new(),
5243            protocol: ModuleProtocol::Subc,
5244            overlap: Default::default(),
5245        });
5246
5247        assert!(
5248            handle.removal_tombstone_age_ms(module_id).is_none(),
5249            "a re-added module must not retain a stale removal tombstone"
5250        );
5251    }
5252
5253    /// What one module's owner looked like from the control plane at the
5254    /// instant after its first process was spawned.
5255    #[derive(Debug, PartialEq, Eq)]
5256    struct OwnerInSpawnWindow {
5257        module_id: String,
5258        configured: bool,
5259        on_roster: bool,
5260        admission_refusal: Option<&'static str>,
5261    }
5262
5263    /// A supervised module's process can connect, register, sync its scopes
5264    /// and describe them as soon as it is spawned, which is BEFORE the
5265    /// supervisor puts the module on the roster. In that window the owner must
5266    /// already read as configured, so a scoped `route.open` against it is
5267    /// refused as retryable `scope_not_synced` and not as terminal
5268    /// `scope_not_live` ("will never sync").
5269    ///
5270    /// The hook runs in exactly that window on every path that takes on a new
5271    /// module, so no race with a real child is needed: `on_roster: false`
5272    /// proves each observation was taken before the roster insert.
5273    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5274    async fn a_new_module_is_configured_before_its_first_process_can_register() {
5275        use crate::scopes::ScopeTable;
5276        use subc_protocol::{error_codes, scope::ScopeSelector, Principal};
5277
5278        let dir = cortexkit_test_support::ScratchDir::new("configured-before-spawn");
5279        let stub = |module_id: &str, program: PathBuf| ModuleSpec {
5280            module_id: module_id.to_string(),
5281            program,
5282            args: Vec::new(),
5283            env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
5284                .into_iter()
5285                .map(|key| (key.to_string(), dir.path().display().to_string()))
5286                .chain([("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string())])
5287                .collect(),
5288            reserved: false,
5289            reserved_prefixes: Vec::new(),
5290            protocol: ModuleProtocol::Subc,
5291            overlap: Default::default(),
5292        };
5293        let live = super::terminal_history_tests::fake_aft_stub_path();
5294        let missing = dir.path().join("definitely-missing-module");
5295
5296        let handle = SupervisorHandle::new();
5297        let mut supervisor =
5298            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5299                .with_handle(handle.clone());
5300        let observed = Arc::new(Mutex::new(Vec::<OwnerInSpawnWindow>::new()));
5301        let hook_handle = handle.clone();
5302        let hook_observed = Arc::clone(&observed);
5303        supervisor.test_after_first_spawn = AfterFirstSpawnHook(Some(Arc::new(move |module_id| {
5304            // Exactly what the control plane computes for a scoped route.open
5305            // naming this module as the owner of a scope it has not synced.
5306            let configured = hook_handle.is_configured(module_id);
5307            let selector = ScopeSelector {
5308                owner: Principal::Reserved {
5309                    module_id: module_id.to_string(),
5310                },
5311                scope_ref: "s".to_string(),
5312                scope_epoch: Some(1),
5313            };
5314            let carrier = Principal::Reserved {
5315                module_id: "carrier".to_string(),
5316            };
5317            let admission_refusal = match ScopeTable::new(Vec::<String>::new())
5318                .admit(&carrier, module_id, &selector, configured)
5319            {
5320                Ok(_) => None,
5321                Err(refusal) => Some(refusal.code),
5322            };
5323            hook_observed.lock().unwrap().push(OwnerInSpawnWindow {
5324                module_id: module_id.to_string(),
5325                configured,
5326                on_roster: hook_handle.get(module_id).is_some(),
5327                admission_refusal,
5328            });
5329        })));
5330
5331        let plain = supervisor.spawn(stub("plain", live.clone())).unwrap();
5332        let configured = supervisor
5333            .supervise_configured(stub("configured", live.clone()), true)
5334            .unwrap();
5335        let with_health = supervisor
5336            .supervise_configured_with_health(
5337                stub("with-health", live.clone()),
5338                true,
5339                HealthConfig::default(),
5340                None,
5341                RestartPolicy::default(),
5342            )
5343            .unwrap();
5344        // The failed-spawn path still puts the module on the roster (as
5345        // failed), so it is configured throughout.
5346        let failed = supervisor
5347            .supervise_configured_with_health(
5348                stub("failed-spawn", missing.clone()),
5349                true,
5350                HealthConfig::default(),
5351                None,
5352                RestartPolicy::default(),
5353            )
5354            .unwrap();
5355        // A failed plain `spawn` puts nothing on the roster, so its mark is
5356        // taken back once the spawn has failed.
5357        assert!(supervisor.spawn(stub("spawn-error", missing)).is_err());
5358
5359        let expected = [
5360            "plain",
5361            "configured",
5362            "with-health",
5363            "failed-spawn",
5364            "spawn-error",
5365        ]
5366        .into_iter()
5367        .map(|module_id| OwnerInSpawnWindow {
5368            module_id: module_id.to_string(),
5369            configured: true,
5370            on_roster: false,
5371            admission_refusal: Some(error_codes::SCOPE_NOT_SYNCED),
5372        })
5373        .collect::<Vec<_>>();
5374        assert_eq!(*observed.lock().unwrap(), expected);
5375
5376        for module_id in ["plain", "configured", "with-health", "failed-spawn"] {
5377            assert!(
5378                handle.get(module_id).is_some(),
5379                "{module_id} is on the roster"
5380            );
5381            assert!(
5382                handle.is_configured(module_id),
5383                "{module_id} stays configured"
5384            );
5385        }
5386        assert!(handle.get("spawn-error").is_none());
5387        assert!(
5388            !handle.is_configured("spawn-error"),
5389            "a plain spawn that failed must not leave its module marked configured"
5390        );
5391
5392        // Leaving the roster clears the mark with it.
5393        handle.retire("failed-spawn");
5394        assert!(!handle.is_configured("failed-spawn"));
5395
5396        for module in [plain, configured, with_health] {
5397            module.stop().await.unwrap();
5398        }
5399        drop(failed);
5400    }
5401
5402    fn stale_process_snapshot(state: ModuleState, enabled: bool) -> SharedSnapshot {
5403        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(state, enabled)));
5404        update_snapshot(&snapshot, Some("stale-process-facts"), |snapshot| {
5405            snapshot.process_alive = true;
5406            snapshot.pid = Some(41);
5407            snapshot.spawned_at_ms = Some(42);
5408            snapshot.spawned_from = Some(PathBuf::from("/spawned/module"));
5409            snapshot.spawned_file_identity = Some(SpawnedFileIdentity {
5410                device: 43,
5411                inode: 44,
5412                #[cfg(windows)]
5413                windows_image: None,
5414            });
5415        })
5416        .unwrap();
5417        snapshot
5418    }
5419
5420    fn assert_snapshot_process_facts_cleared(snapshot: &SharedSnapshot) {
5421        let snapshot = lock_snapshot(snapshot).unwrap();
5422        assert!(!snapshot.process_alive);
5423        assert_eq!(snapshot.pid, None);
5424        assert_eq!(snapshot.spawned_at_ms, None);
5425        assert_eq!(snapshot.spawned_from, None);
5426        assert_eq!(snapshot.spawned_file_identity, None);
5427    }
5428
5429    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5430    async fn failed_enable_spawn_clears_preexisting_current_process_facts() {
5431        let supervisor =
5432            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5433        let mut runtime = supervisor.runtime_config();
5434        runtime.test_seed_stale_facts_before_enable_spawn = true;
5435        let snapshot = stale_process_snapshot(ModuleState::Disabled, false);
5436        let mut child = None;
5437        let spec = ModuleSpec {
5438            module_id: "failed-enable-clears-facts".to_string(),
5439            program: PathBuf::from("/definitely/missing/failed-enable-module"),
5440            args: Vec::new(),
5441            env: Vec::new(),
5442            reserved: false,
5443            reserved_prefixes: Vec::new(),
5444            protocol: ModuleProtocol::Subc,
5445            overlap: Default::default(),
5446        };
5447
5448        let result = set_child_enabled(
5449            &spec,
5450            &runtime,
5451            &supervisor.registry,
5452            &supervisor.process_liveness,
5453            &snapshot,
5454            &mut child,
5455            true,
5456        )
5457        .await;
5458
5459        assert!(matches!(result, Err(SuperviseError::Spawn { .. })));
5460        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
5461        assert_snapshot_process_facts_cleared(&snapshot);
5462    }
5463
5464    #[tokio::test]
5465    async fn start_revives_stranded_restarting_but_not_pending_backoff() {
5466        let supervisor =
5467            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5468        let runtime = supervisor.runtime_config();
5469        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(
5470            ModuleState::Restarting,
5471            true,
5472        )));
5473        let spec = ModuleSpec {
5474            module_id: "start-stranded-restarting".to_string(),
5475            program: super::terminal_history_tests::fake_aft_stub_path(),
5476            args: Vec::new(),
5477            env: vec![("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string())],
5478            reserved: false,
5479            reserved_prefixes: Vec::new(),
5480            protocol: ModuleProtocol::None,
5481            overlap: Default::default(),
5482        };
5483        let mut child = None;
5484        lock_snapshot(&snapshot).unwrap().respawn_pending = true;
5485        assert!(!super::set_child_enabled(
5486            &spec,
5487            &runtime,
5488            &Registry::default(),
5489            &supervisor.process_liveness,
5490            &snapshot,
5491            &mut child,
5492            true
5493        )
5494        .await
5495        .unwrap());
5496        assert!(child.is_none());
5497        lock_snapshot(&snapshot).unwrap().respawn_pending = false;
5498        assert!(super::set_child_enabled(
5499            &spec,
5500            &runtime,
5501            &Registry::default(),
5502            &supervisor.process_liveness,
5503            &snapshot,
5504            &mut child,
5505            true
5506        )
5507        .await
5508        .unwrap());
5509        assert_eq!(
5510            lock_snapshot(&snapshot).unwrap().state,
5511            ModuleState::Running
5512        );
5513        let mut child = child.unwrap();
5514        child.start_kill().unwrap();
5515        child.wait().await.unwrap();
5516    }
5517
5518    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5519    async fn failed_reload_spawn_clears_current_process_facts() {
5520        let supervisor =
5521            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5522        let mut runtime = supervisor.runtime_config();
5523        runtime.restart_policy = RestartPolicy::new(0, Duration::ZERO);
5524        let snapshot = stale_process_snapshot(ModuleState::Running, true);
5525        let mut child = None;
5526        let spec = ModuleSpec {
5527            module_id: "failed-reload-clears-facts".to_string(),
5528            program: PathBuf::from("/unused/failed-reload-module"),
5529            args: Vec::new(),
5530            env: Vec::new(),
5531            reserved: false,
5532            reserved_prefixes: Vec::new(),
5533            protocol: ModuleProtocol::Subc,
5534            overlap: Default::default(),
5535        };
5536
5537        let result = handle_reload_spawn_failure(
5538            &spec,
5539            &runtime,
5540            &supervisor.process_liveness,
5541            &snapshot,
5542            &mut child,
5543            "forced reload spawn failure".to_string(),
5544        )
5545        .await;
5546
5547        assert!(matches!(result, Err(SuperviseError::ReloadFailed { .. })));
5548        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
5549        assert_snapshot_process_facts_cleared(&snapshot);
5550    }
5551
5552    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5553    async fn dropping_a_module_with_an_active_monitor_clears_current_process_facts() {
5554        let supervisor =
5555            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5556        let snapshot = stale_process_snapshot(ModuleState::Running, true);
5557        let module = supervisor.supervised_module(
5558            ModuleSpec {
5559                module_id: "drop-clears-facts".to_string(),
5560                program: PathBuf::from("/unused/drop-module"),
5561                args: Vec::new(),
5562                env: Vec::new(),
5563                reserved: false,
5564                reserved_prefixes: Vec::new(),
5565                protocol: ModuleProtocol::Subc,
5566                overlap: Default::default(),
5567            },
5568            supervisor.runtime_config(),
5569            Arc::clone(&snapshot),
5570            None,
5571        );
5572        assert!(!module
5573            .inner
5574            .monitor
5575            .lock()
5576            .unwrap()
5577            .as_ref()
5578            .unwrap()
5579            .is_finished());
5580
5581        drop(module);
5582
5583        assert_eq!(
5584            lock_snapshot(&snapshot).unwrap().state,
5585            ModuleState::Stopped
5586        );
5587        assert_snapshot_process_facts_cleared(&snapshot);
5588    }
5589
5590    #[cfg(unix)]
5591    #[tokio::test]
5592    async fn rescan_preserves_running_protocol_until_respawn() {
5593        let dir = cortexkit_test_support::ScratchDir::new("rescan-protocol");
5594        let initial = ModuleSpec {
5595            module_id: "rescan-protocol".into(),
5596            program: PathBuf::from("/bin/sleep"),
5597            args: vec!["60".into()],
5598            env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
5599                .into_iter()
5600                .map(|key| (key.into(), dir.path().to_string_lossy().into_owned()))
5601                .collect(),
5602            reserved: false,
5603            reserved_prefixes: vec![],
5604            protocol: ModuleProtocol::None,
5605            overlap: Default::default(),
5606        };
5607        let supervisor =
5608            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5609        let module = supervisor.spawn(initial.clone()).unwrap();
5610        assert!(module.status().unwrap().live);
5611        let mut next = initial;
5612        next.protocol = ModuleProtocol::Subc;
5613        module
5614            .update_configuration(next.clone(), HealthConfig::default(), None)
5615            .await
5616            .unwrap();
5617        assert!(
5618            module.status().unwrap().live,
5619            "rescan must not require HELLO from the old non-wire process"
5620        );
5621        let runtime = supervisor.runtime_config();
5622        let action = on_child_exit(
5623            &next,
5624            RestartPolicy::default(),
5625            &supervisor.registry,
5626            &module.inner.snapshot,
5627            &runtime.terminal_ring,
5628            &runtime.spawn_events,
5629            &runtime.child_roster,
5630            ExitReport {
5631                kind: ExitKind::Clean,
5632                code: Some(0),
5633                signal: None,
5634                at_ms: unix_ms_now(),
5635            },
5636        )
5637        .await;
5638        assert!(
5639            matches!(action, NextAction::Restart { .. }),
5640            "the old non-wire process's clean exit must restart"
5641        );
5642        module.drain().await.unwrap();
5643    }
5644
5645    #[cfg(unix)]
5646    #[tokio::test]
5647    async fn orphan_identity_matches_path_names_and_shebang_interpreters() {
5648        use std::os::unix::fs::PermissionsExt;
5649        let dir = cortexkit_test_support::ScratchDir::new("spawn-image-identity");
5650        let script = dir.join("module.sh");
5651        std::fs::write(&script, "#!/bin/sh\nwhile :; do sleep 0.1; done\n").unwrap();
5652        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700)).unwrap();
5653        let record_path = dir.join("live-children.json");
5654        let supervisor =
5655            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5656                .with_live_children_record(&record_path);
5657        for (program, args) in [
5658            (PathBuf::from("sleep"), vec!["60".into()]),
5659            (script, vec![]),
5660        ] {
5661            let spec = ModuleSpec {
5662                module_id: "image-identity".into(),
5663                program: program.clone(),
5664                args,
5665                env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
5666                    .into_iter()
5667                    .map(|key| (key.into(), dir.to_string_lossy().into_owned()))
5668                    .collect(),
5669                reserved: false,
5670                reserved_prefixes: vec![],
5671                protocol: ModuleProtocol::None,
5672                overlap: Default::default(),
5673            };
5674            let module = supervisor.spawn(spec).unwrap();
5675            #[cfg(target_os = "macos")]
5676            {
5677                // SETEXEC confirmation is asynchronous; the orphan record must
5678                // identify the final image, never the intermediate trampoline.
5679                let deadline = Instant::now() + Duration::from_secs(5);
5680                while crate::live_children::read_record(&record_path)
5681                    .unwrap()
5682                    .iter()
5683                    .all(|entry| entry.executable.is_none())
5684                {
5685                    assert!(Instant::now() < deadline, "module image was not confirmed");
5686                    tokio::time::sleep(Duration::from_millis(5)).await;
5687                }
5688            }
5689            let entry = crate::live_children::read_record(&record_path)
5690                .unwrap()
5691                .pop()
5692                .unwrap();
5693            let observed = subc_os::Process::open(entry.pid)
5694                .unwrap()
5695                .unwrap()
5696                .observe()
5697                .unwrap();
5698            let verdict = crate::live_children::identity_verdict(&entry, entry.pid, &observed);
5699            module.drain().await.unwrap();
5700            assert_eq!(
5701                verdict,
5702                crate::live_children::IdentityVerdict::Matches,
5703                "program {program:?}: recorded {entry:?}, observed {observed:?}"
5704            );
5705        }
5706    }
5707
5708    #[cfg(unix)]
5709    fn http_fixture(
5710        dir: &std::path::Path,
5711        url: &str,
5712        threshold: u32,
5713    ) -> crate::daemon_config::ConfiguredModule {
5714        let path = dir.join("subc.jsonc");
5715        std::fs::write(&path, serde_json::json!({"version": 1, "modules": {"http-process": {
5716            "program": "/bin/sleep", "args": ["60"], "protocol": "none",
5717            "env": {"XDG_DATA_HOME":dir,"XDG_RUNTIME_DIR":dir,"XDG_CONFIG_HOME":dir},
5718            "health": {"http":url,"cadence_ms":20,"deadline_ms":200,"failure_threshold":threshold},
5719            "restart": {"backoff_ms":1,"max_backoff_ms":1}, "drain_timeout_ms":10
5720        }}}).to_string()).unwrap();
5721        crate::daemon_config::load(&path)
5722            .unwrap()
5723            .unwrap()
5724            .modules
5725            .pop()
5726            .unwrap()
5727    }
5728
5729    #[cfg(unix)]
5730    async fn wait_http_health(module: &SupervisedModule, status: SupervisorHealthStatus) {
5731        timeout(Duration::from_secs(5), async {
5732            loop {
5733                if module.status().unwrap().health.status == status {
5734                    break;
5735                }
5736                sleep(Duration::from_millis(5)).await;
5737            }
5738        })
5739        .await
5740        .unwrap_or_else(|_| {
5741            panic!(
5742                "expected {status:?}, got {:?}",
5743                module.status().unwrap().health
5744            )
5745        });
5746    }
5747
5748    #[cfg(unix)]
5749    #[tokio::test]
5750    async fn http_health_status_flips_ok_failing_ok() {
5751        use tokio::io::{AsyncReadExt, AsyncWriteExt};
5752        let dir = cortexkit_test_support::ScratchDir::new("http-status-flips");
5753        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5754        let status = Arc::new(std::sync::atomic::AtomicU16::new(200));
5755        let serving_status = status.clone();
5756        let url = format!("http://{}/healthz", listener.local_addr().unwrap());
5757        let server = tokio::spawn(async move {
5758            loop {
5759                let (mut stream, _) = listener.accept().await.unwrap();
5760                let mut request = [0u8; 2048];
5761                let count = stream.read(&mut request).await.unwrap();
5762                assert!(count > 0, "a probe must send an HTTP request");
5763                let code = serving_status.load(std::sync::atomic::Ordering::SeqCst);
5764                let body = if code == 200 {
5765                    "ready"
5766                } else {
5767                    "{\"status\":\"unavailable\",\"error\":\"scratch failure\"}"
5768                };
5769                let response = format!("HTTP/1.1 {code} scratch\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
5770                let _ = stream.write_all(response.as_bytes()).await;
5771            }
5772        });
5773        let configured = http_fixture(&dir, &url, 1000);
5774        let module =
5775            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5776                .supervise_configured_with_health(
5777                    configured.module_spec(),
5778                    true,
5779                    configured.health,
5780                    configured.drain_timeout_ms,
5781                    configured.restart,
5782                )
5783                .unwrap();
5784        wait_http_health(&module, SupervisorHealthStatus::Ok).await;
5785        status.store(503, std::sync::atomic::Ordering::SeqCst);
5786        wait_http_health(&module, SupervisorHealthStatus::Failing).await;
5787        assert!(module
5788            .status()
5789            .unwrap()
5790            .health
5791            .detail
5792            .unwrap()
5793            .contains("scratch failure"));
5794        status.store(200, std::sync::atomic::Ordering::SeqCst);
5795        wait_http_health(&module, SupervisorHealthStatus::Ok).await;
5796        assert_eq!(module.status().unwrap().health.consecutive_failures, 0);
5797        let before = module.status().unwrap();
5798        let (spec, mut health) = module.configuration().unwrap();
5799        health.http = None;
5800        module
5801            .update_configuration(spec.clone(), health.clone(), Some(10))
5802            .await
5803            .unwrap();
5804        wait_http_health(&module, SupervisorHealthStatus::Unknown).await;
5805        health.http = Some(url);
5806        module
5807            .update_configuration(spec, health, Some(10))
5808            .await
5809            .unwrap();
5810        wait_http_health(&module, SupervisorHealthStatus::Ok).await;
5811        assert_eq!(
5812            module.status().unwrap().pid,
5813            before.pid,
5814            "changing a probe must apply live, not restart its process"
5815        );
5816        let (spec, mut health) = module.configuration().unwrap();
5817        health.failure_threshold = 2;
5818        module
5819            .update_configuration(spec, health, Some(10))
5820            .await
5821            .unwrap();
5822        status.store(503, std::sync::atomic::Ordering::SeqCst);
5823        timeout(Duration::from_secs(5), async {
5824            while module.status().unwrap().spawn_generation == before.spawn_generation {
5825                sleep(Duration::from_millis(5)).await;
5826            }
5827        })
5828        .await
5829        .expect("sustained HTTP 503 responses must trigger the health restart policy");
5830        module.drain().await.unwrap();
5831        server.abort();
5832    }
5833
5834    #[cfg(unix)]
5835    #[tokio::test]
5836    async fn http_health_no_listener_restarts_after_consecutive_failures() {
5837        let dir = cortexkit_test_support::ScratchDir::new("http-refused");
5838        let unused = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5839        let url = format!("http://{}/healthz", unused.local_addr().unwrap());
5840        drop(unused);
5841        let configured = http_fixture(&dir, &url, 2);
5842        let module =
5843            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5844                .supervise_configured_with_health(
5845                    configured.module_spec(),
5846                    true,
5847                    configured.health,
5848                    configured.drain_timeout_ms,
5849                    configured.restart,
5850                )
5851                .unwrap();
5852        let before = module.status().unwrap().spawn_generation;
5853        timeout(Duration::from_secs(5), async {
5854            loop {
5855                let status = module.status().unwrap();
5856                if status.spawn_generation > before {
5857                    assert!(status.lifetime_restarts > 0);
5858                    break;
5859                }
5860                sleep(Duration::from_millis(5)).await;
5861            }
5862        })
5863        .await
5864        .expect("sustained HTTP refusal must trigger the health restart policy");
5865        module.drain().await.unwrap();
5866    }
5867
5868    #[cfg(unix)]
5869    #[tokio::test]
5870    async fn http_health_timeout_honours_deadline() {
5871        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5872        let url = format!("http://{}/healthz", listener.local_addr().unwrap());
5873        let server = tokio::spawn(async move {
5874            let _held = listener.accept().await.unwrap();
5875            std::future::pending::<()>().await;
5876        });
5877        let error = timeout(
5878            Duration::from_secs(1),
5879            probe_http_health(&url, Duration::from_millis(10)),
5880        )
5881        .await
5882        .expect("the probe must enforce its own deadline")
5883        .unwrap_err();
5884        server.abort();
5885        assert!(error.to_string().contains("timed out"));
5886    }
5887
5888    #[cfg(unix)]
5889    #[tokio::test]
5890    async fn http_health_localhost_reaches_an_ipv6_only_listener() {
5891        use tokio::io::{AsyncReadExt, AsyncWriteExt};
5892        let listener = tokio::net::TcpListener::bind("[::1]:0").await.unwrap();
5893        let url = format!(
5894            "http://localhost:{}/healthz",
5895            listener.local_addr().unwrap().port()
5896        );
5897        let server = tokio::spawn(async move {
5898            let (mut stream, _) = listener.accept().await.unwrap();
5899            let mut request = [0u8; 2048];
5900            assert!(stream.read(&mut request).await.unwrap() > 0);
5901            stream
5902                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
5903                .await
5904                .unwrap();
5905        });
5906        // The deadline only bounds a hang. A probe that never tried the IPv6
5907        // address would be refused on 127.0.0.1 and fail at once, so a longer
5908        // deadline does not weaken the assertion; one second timed out under a
5909        // loaded parallel test run.
5910        assert_eq!(
5911            probe_http_health(&url, Duration::from_secs(10))
5912                .await
5913                .unwrap()
5914                .status,
5915            HealthStatus::Ok
5916        );
5917        server.await.unwrap();
5918    }
5919
5920    #[cfg(unix)]
5921    #[tokio::test]
5922    async fn http_health_timeout_keeps_partial_status_and_body() {
5923        use tokio::io::{AsyncReadExt, AsyncWriteExt};
5924        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5925        let url = format!("http://{}/healthz", listener.local_addr().unwrap());
5926        let server = tokio::spawn(async move {
5927            let (mut stream, _) = listener.accept().await.unwrap();
5928            let mut request = [0u8; 2048];
5929            assert!(stream.read(&mut request).await.unwrap() > 0);
5930            stream
5931                .write_all(
5932                    b"HTTP/1.1 503 Unavailable\r\nContent-Length: 100\r\n\r\npartial diagnostic",
5933                )
5934                .await
5935                .unwrap();
5936            std::future::pending::<()>().await;
5937        });
5938        let error = probe_http_health(&url, Duration::from_secs(1))
5939            .await
5940            .unwrap_err()
5941            .to_string();
5942        server.abort();
5943        assert!(
5944            error.contains("timed out")
5945                && error.contains("503 Unavailable")
5946                && error.contains("partial diagnostic"),
5947            "{error}"
5948        );
5949    }
5950
5951    #[cfg(unix)]
5952    #[tokio::test]
5953    async fn http_health_diagnostic_body_is_bounded_to_200_bytes() {
5954        use tokio::io::{AsyncReadExt, AsyncWriteExt};
5955        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5956        let url = format!("http://{}/healthz", listener.local_addr().unwrap());
5957        let server = tokio::spawn(async move {
5958            let (mut stream, _) = listener.accept().await.unwrap();
5959            let mut request = [0u8; 2048];
5960            assert!(stream.read(&mut request).await.unwrap() > 0);
5961            let body = format!("{}not-in-diagnostic", "x".repeat(400));
5962            let response = format!(
5963                "HTTP/1.1 503 Unavailable\r\nContent-Length: {}\r\n\r\n{body}",
5964                body.len()
5965            );
5966            stream.write_all(response.as_bytes()).await.unwrap();
5967        });
5968        let error = probe_http_health(&url, Duration::from_secs(1))
5969            .await
5970            .unwrap_err()
5971            .to_string();
5972        server.await.unwrap();
5973        assert!(error.ends_with(&"x".repeat(200)), "{error}");
5974        assert_eq!(error.split(": ").last().unwrap().len(), 200);
5975        assert!(!error.contains("not-in-diagnostic"));
5976    }
5977
5978    #[cfg(unix)]
5979    #[tokio::test]
5980    async fn http_health_real_nats_server_monitoring() {
5981        if std::process::Command::new("nats-server")
5982            .arg("--version")
5983            .env("XDG_DATA_HOME", std::env::temp_dir())
5984            .env("XDG_RUNTIME_DIR", std::env::temp_dir())
5985            .env("XDG_CONFIG_HOME", std::env::temp_dir())
5986            .output()
5987            .is_err()
5988        {
5989            eprintln!(
5990                "skipped: http_health_real_nats_server_monitoring (nats-server is not on PATH)"
5991            );
5992            return;
5993        }
5994        let dir = cortexkit_test_support::ScratchDir::new("nats-http-monitoring");
5995        let monitor = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5996        let port = monitor.local_addr().unwrap().port();
5997        let client = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5998        let client_port = client.local_addr().unwrap().port();
5999        let config = dir.join("server.conf");
6000        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();
6001        let mut configured = http_fixture(&dir, &format!("http://127.0.0.1:{port}/healthz"), 1000);
6002        configured.program = PathBuf::from("nats-server");
6003        configured.args = vec!["-c".into(), config.to_string_lossy().into_owned()];
6004        drop(monitor);
6005        drop(client);
6006        let module =
6007            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
6008                .supervise_configured_with_health(
6009                    configured.module_spec(),
6010                    true,
6011                    configured.health,
6012                    configured.drain_timeout_ms,
6013                    configured.restart,
6014                )
6015                .unwrap();
6016        wait_http_health(&module, SupervisorHealthStatus::Ok).await;
6017        module.drain().await.unwrap();
6018    }
6019
6020    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
6021    async fn configuration_update_does_not_replace_captured_running_process_facts() {
6022        let supervisor =
6023            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
6024        let snapshot = stale_process_snapshot(ModuleState::Running, true);
6025        let initial = ModuleSpec {
6026            module_id: "rescan-preserves-spawn-facts".to_string(),
6027            program: PathBuf::from("/spawned/module"),
6028            args: Vec::new(),
6029            env: Vec::new(),
6030            reserved: false,
6031            reserved_prefixes: Vec::new(),
6032            protocol: ModuleProtocol::Subc,
6033            overlap: Default::default(),
6034        };
6035        let module = supervisor.supervised_module(
6036            initial.clone(),
6037            supervisor.runtime_config(),
6038            snapshot,
6039            None,
6040        );
6041        let before = module.status().unwrap();
6042        let mut replacement = initial;
6043        replacement.program = PathBuf::from("/rescanned/replacement-module");
6044
6045        module
6046            .update_configuration(replacement, HealthConfig::default(), None)
6047            .await
6048            .unwrap();
6049
6050        let after = module.status().unwrap();
6051        assert_eq!(after.pid, before.pid);
6052        assert_eq!(after.spawned_at_ms, before.spawned_at_ms);
6053        assert_eq!(after.spawned_from, before.spawned_from);
6054        drop(module);
6055    }
6056}
6057
6058fn unix_ms_now() -> u64 {
6059    SystemTime::now()
6060        .duration_since(UNIX_EPOCH)
6061        .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
6062        .unwrap_or(0)
6063}
6064
6065async fn supervise_loop(
6066    mut spec: ModuleSpec,
6067    mut runtime: SupervisorRuntimeConfig,
6068    registry: Arc<Registry>,
6069    process_liveness: Arc<SupervisorProcessLiveness>,
6070    snapshot: SharedSnapshot,
6071    mut child: Option<SupervisedChild>,
6072    mut commands: mpsc::Receiver<SupervisorCommand>,
6073) {
6074    let mut health_probe = HealthProbeRuntime::default();
6075    // Registry writes (including embedded callers) notify this module only.
6076    // Subscribe before the first refresh; watch retains changes that arrive
6077    // while commands or probes are running. No polling fallback is needed.
6078    let mut registration_changes = match registry.subscribe_module_changes(&spec.module_id) {
6079        Ok(changes) => Some(changes),
6080        Err(err) => {
6081            // A poisoned registry cannot accept further writes, so it cannot
6082            // recover via a timer. Keep exit and command handling alive.
6083            warn!(module_id = %spec.module_id, error = %err, "health prober could not subscribe to registry");
6084            None
6085        }
6086    };
6087    // All restart backoffs run here, including health and operator requests.
6088    // While one is pending the loop serves commands, so disable or drain can
6089    // cancel the replacement without spawning a process just to stop it.
6090    let mut pending_respawn: Option<PendingRespawn> = None;
6091    // Commands a swap handed back to run next (see `swap::SwapEnd`). Served
6092    // before anything else so a stop that interrupted a swap runs at once.
6093    let mut requeued: VecDeque<SupervisorCommand> = VecDeque::new();
6094    #[cfg(target_os = "linux")]
6095    let mut previous_instance_recheck = tokio::time::interval_at(
6096        Instant::now() + Duration::from_secs(5),
6097        Duration::from_secs(5),
6098    );
6099    #[cfg(target_os = "linux")]
6100    previous_instance_recheck.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
6101    loop {
6102        #[cfg(test)]
6103        {
6104            lock_snapshot(&snapshot).unwrap().actor_select = None;
6105        }
6106        #[cfg(target_os = "macos")]
6107        if let Some(active) = child.as_mut() {
6108            active.confirm_privacy_exec().await;
6109        }
6110        if let Some(scheduled) = runtime
6111            .scheduled_respawn
6112            .lock()
6113            .unwrap_or_else(|p| p.into_inner())
6114            .take()
6115        {
6116            pending_respawn = Some(scheduled);
6117        }
6118        if pending_respawn.is_some() && (child.is_some() || !respawn_still_pending(&snapshot)) {
6119            pending_respawn = None;
6120            cancel_deferred_reload(
6121                &runtime,
6122                &spec.module_id,
6123                "respawn cancelled by a supervisor command",
6124            );
6125        }
6126        if child.is_none()
6127            && pending_respawn.is_none()
6128            && lock_snapshot(&snapshot).is_ok_and(|state| state.waiting_for_previous_pid.is_none())
6129        {
6130            cancel_deferred_reload(
6131                &runtime,
6132                &spec.module_id,
6133                "respawn cancelled before a replacement was spawned",
6134            );
6135            let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6136                state.respawn_pending = false;
6137                state.coalesced_restart_pending = false;
6138                if matches!(
6139                    state.state,
6140                    ModuleState::Restarting
6141                        | ModuleState::Starting
6142                        | ModuleState::Draining
6143                        | ModuleState::Unresponsive
6144                ) {
6145                    error!(module_id = %spec.module_id, state = ?state.state,
6146                        "supervision operation ended without a child or pending respawn; marking failed so start can retry");
6147                    state.state = ModuleState::Failed;
6148                    clear_current_process_facts(state);
6149                }
6150            });
6151        }
6152        if let Some(command) = requeued.pop_front() {
6153            if !handle_supervisor_command(
6154                command,
6155                &mut spec,
6156                &mut runtime,
6157                &registry,
6158                &process_liveness,
6159                &snapshot,
6160                &mut child,
6161                &mut commands,
6162                &mut requeued,
6163            )
6164            .await
6165            {
6166                return;
6167            }
6168            if child.is_some() || !respawn_still_pending(&snapshot) {
6169                pending_respawn = None;
6170                let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6171                    state.respawn_pending = false
6172                });
6173            }
6174            continue;
6175        }
6176        if child.is_some() {
6177            #[cfg(test)]
6178            {
6179                lock_snapshot(&snapshot).unwrap().actor_turns += 1;
6180            }
6181            health_probe.refresh_registration(&spec, &runtime, &registry, &snapshot);
6182            let wake_after = health_probe.wake_after();
6183            let probe_sleep = async move {
6184                match wake_after {
6185                    Some(delay) => sleep(delay).await,
6186                    None => std::future::pending().await,
6187                }
6188            };
6189            tokio::pin!(probe_sleep);
6190            // A `protocol: "none"` child (a plain process that never registers
6191            // over the subc wire, such as nats-server) with no HTTP health
6192            // check has nothing to probe, so it waits only for its exit or a
6193            // supervisor command. A rescan that adds an HTTP check is a command.
6194            let wire_child = running_protocol(&spec, &snapshot) == ModuleProtocol::Subc;
6195            #[cfg(test)]
6196            {
6197                let registration_pending = wire_child
6198                    && registration_changes
6199                        .as_ref()
6200                        .is_some_and(|changes| changes.has_changed().unwrap());
6201                let mut state = lock_snapshot(&snapshot).unwrap();
6202                state.actor_select = (!registration_pending).then_some(ActorSelectCheckpoint {
6203                    generation: state.spawn_generation,
6204                    turn: state.actor_turns,
6205                    registered_connection: health_probe.registered_connection,
6206                    next_probe_at: health_probe.next_probe_at,
6207                    wake_after,
6208                });
6209            }
6210            let active_child = child.as_mut().expect("child checked above");
6211            tokio::select! {
6212                wait_result = active_child.wait() => {
6213                    #[cfg(test)]
6214                    { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6215                    // Every arm below that gives up on the CHILD must keep the
6216                    // supervision task itself alive (child = None, loop
6217                    // continues into command-serving mode). Returning here
6218                    // closes the command channel, which makes the module
6219                    // permanently unrestartable in-band: a clean child exit
6220                    // of an enabled module once wedged the fleet this way
6221                    // ('supervisor command channel is closed') and required a
6222                    // full daemon restart to recover.
6223                    let exit_report = match wait_result {
6224                        Ok(status) => classify_reaped_child_exit(&snapshot, active_child, &status),
6225                        Err(err) => {
6226                            active_child.drain_stderr(&spec.module_id).await;
6227                            fail_after_wait_error(
6228                                &spec.module_id,
6229                                &snapshot,
6230                                &runtime.terminal_ring,
6231                                &runtime.spawn_events,
6232                                format!("failed to wait for supervised module: {err}"),
6233                            );
6234                            untrack_if_registration_released(
6235                                &process_liveness,
6236                                &registry,
6237                                &spec.module_id,
6238                                &snapshot,
6239                            );
6240                            error!(module_id = %spec.module_id, error = %err, "failed to wait for supervised module");
6241                            child = None;
6242                            continue;
6243                        }
6244                    };
6245                    active_child.drain_stderr(&spec.module_id).await;
6246
6247                    let next = on_child_exit(
6248                        &spec,
6249                        runtime.restart_policy,
6250                        &registry,
6251                        &snapshot,
6252                        &runtime.terminal_ring,
6253                        &runtime.spawn_events,
6254                        &runtime.child_roster,
6255                        exit_report,
6256                    ).await;
6257                    // The exit is recorded, so a daemon shutdown may stop
6258                    // waiting for this child (see `SupervisedChild::wait`).
6259                    active_child.release_roster();
6260                    match next {
6261                        NextAction::Stop { registration_released } => {
6262                            if registration_released {
6263                                process_liveness.untrack_if_current(&spec.module_id, &snapshot);
6264                            }
6265                            child = None;
6266                        }
6267                        NextAction::Restart { schedule } => {
6268                            let delay = schedule.map_or(
6269                                runtime.restart_policy.delay_for_restart(0),
6270                                |schedule| schedule.delay,
6271                            );
6272                            if let Some(schedule) = schedule {
6273                                log_crash_respawn(&spec.module_id, schedule);
6274                            }
6275                            // The exited child is fully recorded at this point,
6276                            // so release it and count the backoff down in the
6277                            // command-serving branch below rather than sleeping
6278                            // here: commands cannot be received from inside this
6279                            // select arm, and an operator disable or drain that
6280                            // arrives during the backoff must cancel the pending
6281                            // respawn instead of waiting for it to spawn first.
6282                            child = None;
6283                            pending_respawn = Some(PendingRespawn { deadline: Instant::now() + delay, kind: RespawnKind::Spawn });
6284                             let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| state.respawn_pending = true);
6285                        }
6286                    }
6287                }
6288                command = commands.recv() => {
6289                    #[cfg(test)]
6290                    { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6291                    let Some(command) = command else {
6292                        return;
6293                    };
6294                    if !handle_supervisor_command(
6295                        command,
6296                        &mut spec,
6297                        &mut runtime,
6298                        &registry,
6299                        &process_liveness,
6300                        &snapshot,
6301                        &mut child,
6302                        &mut commands,
6303                        &mut requeued,
6304                    ).await {
6305                        return;
6306                    }
6307                }
6308                _ = async {
6309                    match registration_changes.as_mut() {
6310                        Some(changes) => { let _ = changes.changed().await; }
6311                        None => std::future::pending().await,
6312                    }
6313                }, if wire_child => {
6314                    #[cfg(test)]
6315                    { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6316                }
6317                _ = &mut probe_sleep => {
6318                    #[cfg(test)]
6319                    { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6320                    if health_probe.due() {
6321                        run_health_probe_cycle(
6322                            &spec,
6323                            &runtime,
6324                            &registry,
6325                            &process_liveness,
6326                            &snapshot,
6327                            &mut child,
6328                        ).await;
6329                        if child.is_some() {
6330                            health_probe.schedule_next(&spec, runtime.health.cadence);
6331                        }
6332                    }
6333                }
6334            }
6335        } else if let Some(pending) = pending_respawn {
6336            tokio::select! {
6337                _ = sleep_until(pending.deadline) => {
6338                    pending_respawn = None;
6339                let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| state.respawn_pending = false);
6340                    // A command handled below while the backoff elapsed may
6341                    // have stopped the module; never respawn past an operator's
6342                    // disable or drain.
6343                    if !respawn_still_pending(&snapshot) {
6344                        continue;
6345                    }
6346                    // The daemon began shutting down during the backoff: the
6347                    // spawn would be refused anyway, and refusing it here
6348                    // leaves the module stopped instead of reporting a
6349                    // failed restart.
6350                    if runtime.child_roster.is_closed() {
6351                        let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6352                            state.state = ModuleState::Stopped;
6353                        });
6354                        debug!(module_id = %spec.module_id, "crash respawn cancelled by daemon shutdown");
6355                        continue;
6356                    }
6357                    if let Err(err) = release_dead_registration(
6358                        &registry,
6359                        runtime.forwarding.as_deref(),
6360                        &snapshot,
6361                        &spec.module_id,
6362                    ).await {
6363                        fail_snapshot(&snapshot, Some(&spec.module_id), None);
6364                        error!(module_id = %spec.module_id, error = %err, "registration did not release before restart");
6365                        continue;
6366                    }
6367
6368                    if matches!(pending.kind, RespawnKind::Reload) {
6369                        let reply = runtime.deferred_reload_reply.lock().unwrap_or_else(|p| p.into_inner()).take();
6370                        let result = finish_reload_child(&spec, &runtime, &registry, &process_liveness, &snapshot, &mut child).await;
6371                        if let Some(reply) = reply { let _ = reply.send(result); }
6372                        continue;
6373                    }
6374                    process_liveness.track(spec.module_id.clone(), Arc::clone(&snapshot));
6375                    match spawn_and_mark_running(&spec, &runtime, &snapshot) {
6376                        Ok(next_child) => {
6377                            child = Some(next_child);
6378                            debug!(module_id = %spec.module_id, "supervised module restarted after crash");
6379                        }
6380                        Err(err) => {
6381                            fail_snapshot(&snapshot, Some(&spec.module_id), None);
6382                            process_liveness.untrack_if_current(&spec.module_id, &snapshot);
6383                            error!(module_id = %spec.module_id, error = %err, "failed to restart supervised module");
6384                        }
6385                    }
6386                }
6387                command = commands.recv() => {
6388                    let Some(command) = command else {
6389                        return;
6390                    };
6391                    if !handle_supervisor_command(
6392                        command,
6393                        &mut spec,
6394                        &mut runtime,
6395                        &registry,
6396                        &process_liveness,
6397                        &snapshot,
6398                        &mut child,
6399                        &mut commands,
6400                        &mut requeued,
6401                    ).await {
6402                        return;
6403                    }
6404                    // Reconcile the pending respawn with what the command did:
6405                    // a start may already have spawned a fresh child,
6406                    // while a disable or drain moved the snapshot out of the
6407                    // state the respawn was counting down from.
6408                    if child.is_some() || !respawn_still_pending(&snapshot) {
6409                        pending_respawn = None;
6410                let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| state.respawn_pending = false);
6411                    }
6412                }
6413            }
6414        } else {
6415            #[cfg(target_os = "linux")]
6416            let command = if lock_snapshot(&snapshot)
6417                .is_ok_and(|state| state.waiting_for_previous_pid.is_some())
6418            {
6419                tokio::select! {
6420                    command = commands.recv() => command,
6421                    _ = previous_instance_recheck.tick() => {
6422                        if runtime.child_roster.previous_instance_pid(&spec.module_id).is_none() {
6423                            let enabled = lock_snapshot(&snapshot).is_ok_and(|state| state.enabled);
6424                            if enabled && !runtime.child_roster.is_closed() {
6425                                process_liveness.track(spec.module_id.clone(), Arc::clone(&snapshot));
6426                                match spawn_and_mark_running(&spec, &runtime, &snapshot) {
6427                                    Ok(next_child) => child = Some(next_child),
6428                                    Err(error) => {
6429                                        fail_snapshot(&snapshot, Some(&spec.module_id), None);
6430                                        error!(module_id = %spec.module_id, %error, "failed to start module after its previous instance exited");
6431                                    }
6432                                }
6433                            } else {
6434                                let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6435                                    state.waiting_for_previous_pid = None;
6436                                });
6437                            }
6438                        }
6439                        continue;
6440                    }
6441                }
6442            } else {
6443                commands.recv().await
6444            };
6445            #[cfg(not(target_os = "linux"))]
6446            let command = commands.recv().await;
6447            let Some(command) = command else {
6448                return;
6449            };
6450            if !handle_supervisor_command(
6451                command,
6452                &mut spec,
6453                &mut runtime,
6454                &registry,
6455                &process_liveness,
6456                &snapshot,
6457                &mut child,
6458                &mut commands,
6459                &mut requeued,
6460            )
6461            .await
6462            {
6463                return;
6464            }
6465        }
6466    }
6467}
6468
6469fn log_crash_respawn(module_id: &str, schedule: CrashRestartSchedule) {
6470    info!(
6471        module_id,
6472        restart_in_window = schedule.restart_in_window,
6473        delay_ms = schedule.delay.as_millis() as u64,
6474        "respawning after crash"
6475    );
6476}
6477
6478/// Whether the respawn a backoff was counting down to is still wanted. A
6479/// disable or drain handled while the backoff elapsed moves the snapshot out
6480/// of `Restarting`, and the operator's stop must win over the pending respawn,
6481/// so every sleep-then-spawn path re-validates against the live snapshot
6482/// instead of assuming the state it left behind still holds.
6483fn respawn_still_pending(snapshot: &SharedSnapshot) -> bool {
6484    matches!(
6485        lock_snapshot(snapshot),
6486        Ok(state) if state.enabled && state.state == ModuleState::Restarting
6487    )
6488}
6489
6490enum NextAction {
6491    Stop {
6492        registration_released: bool,
6493    },
6494    Restart {
6495        schedule: Option<CrashRestartSchedule>,
6496    },
6497}
6498
6499#[allow(clippy::too_many_arguments)]
6500async fn handle_supervisor_command(
6501    command: SupervisorCommand,
6502    spec: &mut ModuleSpec,
6503    runtime: &mut SupervisorRuntimeConfig,
6504    registry: &Arc<Registry>,
6505    process_liveness: &SupervisorProcessLiveness,
6506    snapshot: &SharedSnapshot,
6507    child: &mut Option<SupervisedChild>,
6508    commands: &mut mpsc::Receiver<SupervisorCommand>,
6509    requeued: &mut VecDeque<SupervisorCommand>,
6510) -> bool {
6511    #[cfg(target_os = "linux")]
6512    let command = if let Some(pid) = runtime.child_roster.previous_instance_pid(&spec.module_id) {
6513        match command {
6514            SupervisorCommand::Restart { reply, .. }
6515            | SupervisorCommand::Reload { reply }
6516            | SupervisorCommand::Swap { reply, .. } => {
6517                let _ = reply.send(Err(previous_instance_error(spec, pid)));
6518                return true;
6519            }
6520            SupervisorCommand::SetEnabled {
6521                enabled: true,
6522                reply,
6523            } => {
6524                let _ = reply.send(Err(previous_instance_error(spec, pid)));
6525                return true;
6526            }
6527            other => other,
6528        }
6529    } else {
6530        command
6531    };
6532    match command {
6533        SupervisorCommand::Drain { reply } => {
6534            // A plain stop runs no forwarding drain, so nothing reaches the
6535            // module over its connection before the wait: ask by signal.
6536            let result = drain_optional_child(
6537                &spec.module_id,
6538                spec.protocol,
6539                StopNotice::NotSent,
6540                registry,
6541                runtime.forwarding.as_deref(),
6542                snapshot,
6543                &runtime.terminal_ring,
6544                &runtime.spawn_events,
6545                child,
6546                runtime.drain_timeout,
6547                ModuleState::Stopped,
6548                None,
6549            )
6550            .await;
6551            let registration_released = result.is_ok();
6552            let _ = reply.send(result);
6553            if registration_released {
6554                process_liveness.untrack_if_current(&spec.module_id, snapshot);
6555            }
6556            false
6557        }
6558        SupervisorCommand::Retire { reply } => {
6559            let result = async {
6560                let stop_notice = begin_forwarding_drain_if_configured(
6561                    spec,
6562                    runtime,
6563                    registry,
6564                    snapshot,
6565                    None,
6566                    RouteCloseReason::Disable,
6567                )
6568                .await?;
6569                drain_optional_child(
6570                    &spec.module_id,
6571                    spec.protocol,
6572                    stop_notice,
6573                    registry,
6574                    runtime.forwarding.as_deref(),
6575                    snapshot,
6576                    &runtime.terminal_ring,
6577                    &runtime.spawn_events,
6578                    child,
6579                    runtime.drain_timeout,
6580                    ModuleState::Stopped,
6581                    None,
6582                )
6583                .await
6584            }
6585            .await;
6586            let registration_released = result.is_ok();
6587            let _ = reply.send(result);
6588            if registration_released {
6589                process_liveness.untrack_if_current(&spec.module_id, snapshot);
6590            }
6591            false
6592        }
6593        SupervisorCommand::Restart {
6594            drain_timeout_ms,
6595            received_at_generation,
6596            queued_at,
6597            reply,
6598        } => {
6599            // Without this line a restart that waited in the queue (behind a
6600            // health probe cycle or another command) was invisible: the log
6601            // showed only the drain timing out, minutes after the operator's call.
6602            info!(
6603                module_id = %spec.module_id,
6604                queued_ms = u64::try_from(queued_at.elapsed().as_millis()).unwrap_or(u64::MAX),
6605                "restart command dequeued"
6606            );
6607            // ACK AT INITIATION, not completion. The blocking form deadlocked any
6608            // caller whose own request lane rides the module being restarted: the
6609            // caller's in-flight request keeps the drain from quiescing, the drain
6610            // keeps the restart from completing, and the completion keeps the reply
6611            // from releasing the caller — so the drain always timed out and cut the
6612            // initiator with a GOODBYE, even on a healthy module. Replying once the
6613            // restart is validated lets a self-lane caller settle, which is exactly
6614            // what makes the drain succeed. Completion is observable via
6615            // supervisor.list / module status; a post-ack failure lands the module
6616            // in a visible terminal state below rather than in a reply nobody can
6617            // receive.
6618            let validation = match lock_snapshot(snapshot) {
6619                Ok(state) if !state.enabled => Err(SuperviseError::Disabled {
6620                    module_id: spec.module_id.clone(),
6621                }),
6622                Ok(_) => Ok(()),
6623                Err(err) => Err(err),
6624            };
6625            let initiated = validation.is_ok();
6626            let _ = reply.send(validation);
6627            // A restart asks for a fresh process. Commands run one at a time,
6628            // so a restart queued behind another restart (two operator calls
6629            // in quick succession) is dequeued the moment the first one has
6630            // spawned its replacement -- before that process has sent HELLO.
6631            // Running it would drain and kill the process the first restart
6632            // just produced, which is the opposite of what both callers asked
6633            // for. If a process spawned after this request was received is
6634            // still supervised, the request is already satisfied. Not when the
6635            // configuration changed since that spawn: then the newer process
6636            // predates the spec this restart may exist to apply.
6637            let satisfied_by_generation = if initiated && child.is_some() {
6638                lock_snapshot(snapshot).ok().and_then(|state| {
6639                    (state.spawn_generation > received_at_generation
6640                        && !state.configuration_updated_since_spawn)
6641                        .then_some(state.spawn_generation)
6642                })
6643            } else {
6644                None
6645            };
6646            let satisfied_by_pending = initiated
6647                && child.is_none()
6648                && lock_snapshot(snapshot).ok().is_some_and(|mut state| {
6649                    let pending = state.respawn_pending && !state.configuration_updated_since_spawn;
6650                    if pending {
6651                        state.coalesced_restart_pending = true;
6652                    }
6653                    pending
6654                });
6655            if satisfied_by_pending {
6656                debug!(module_id = %spec.module_id, "restart coalesced into the pending replacement");
6657            } else if let Some(generation) = satisfied_by_generation {
6658                info!(
6659                    module_id = %spec.module_id,
6660                    received_at_generation,
6661                    "restart already satisfied by generation {generation}; not restarting again"
6662                );
6663            } else if initiated {
6664                // Precedence: this restart's operator override, else the module's
6665                // configured budget (already resolved into the runtime).
6666                let drain_timeout = drain_timeout_ms
6667                    .map(Duration::from_millis)
6668                    .unwrap_or(runtime.drain_timeout);
6669                if let Err(err) = restart_child(
6670                    spec,
6671                    runtime,
6672                    registry,
6673                    process_liveness,
6674                    snapshot,
6675                    child,
6676                    drain_timeout,
6677                )
6678                .await
6679                {
6680                    warn!(
6681                        module_id = %spec.module_id,
6682                        error = %err,
6683                        "operator restart failed after initiation ack; module state carries the outcome"
6684                    );
6685                    let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
6686                        state.state = ModuleState::Failed;
6687                        clear_current_process_facts(state);
6688                    });
6689                }
6690            }
6691            true
6692        }
6693        SupervisorCommand::Reload { reply } => {
6694            let result =
6695                reload_child(spec, runtime, registry, process_liveness, snapshot, child).await;
6696            if result.is_ok()
6697                && runtime
6698                    .scheduled_respawn
6699                    .lock()
6700                    .unwrap_or_else(|p| p.into_inner())
6701                    .as_ref()
6702                    .is_some_and(|pending| matches!(pending.kind, RespawnKind::Reload))
6703            {
6704                *runtime
6705                    .deferred_reload_reply
6706                    .lock()
6707                    .unwrap_or_else(|p| p.into_inner()) = Some(reply);
6708            } else {
6709                let _ = reply.send(result);
6710            }
6711            true
6712        }
6713        SupervisorCommand::SetEnabled { enabled, reply } => {
6714            let result = set_child_enabled(
6715                spec,
6716                runtime,
6717                registry,
6718                process_liveness,
6719                snapshot,
6720                child,
6721                enabled,
6722            )
6723            .await;
6724            let _ = reply.send(result);
6725            true
6726        }
6727        SupervisorCommand::UpdateConfiguration {
6728            spec: next_spec,
6729            health,
6730            drain_timeout_ms,
6731            reply,
6732        } => {
6733            if let Some(handle) = &runtime.supervisor_handle {
6734                handle.apply_identity_configuration(&next_spec);
6735            }
6736            *spec = next_spec;
6737            let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
6738                state.configuration_updated_since_spawn = true;
6739            });
6740            let health_changed = runtime.health != health;
6741            runtime.health = health;
6742            // Reset the cadence and old endpoint's failure streak on a live
6743            // health-policy change rather than waiting for its old deadline.
6744            if health_changed {
6745                let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
6746                    state.health = ModuleHealthStatus::default();
6747                });
6748            }
6749            runtime.drain_timeout = drain_timeout_ms
6750                .map(Duration::from_millis)
6751                .unwrap_or(runtime.default_drain_timeout);
6752            *runtime
6753                .effective_drain_timeout
6754                .lock()
6755                .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.drain_timeout;
6756            let _ = reply.send(());
6757            true
6758        }
6759        SupervisorCommand::Swap {
6760            ready_timeout,
6761            reply,
6762        } => {
6763            let end = swap::run_swap(
6764                spec,
6765                runtime,
6766                registry,
6767                process_liveness,
6768                snapshot,
6769                child,
6770                commands,
6771                ready_timeout.unwrap_or(DEFAULT_SWAP_READY_TIMEOUT),
6772                reply,
6773            )
6774            .await;
6775            requeued.extend(end.requeue);
6776            true
6777        }
6778    }
6779}
6780
6781async fn restart_child(
6782    spec: &ModuleSpec,
6783    runtime: &SupervisorRuntimeConfig,
6784    registry: &Registry,
6785    process_liveness: &SupervisorProcessLiveness,
6786    snapshot: &SharedSnapshot,
6787    child: &mut Option<SupervisedChild>,
6788    drain_timeout: Duration,
6789) -> Result<(), SuperviseError> {
6790    // Restart cycles a running module; it must not silently start a disabled one.
6791    if !lock_snapshot(snapshot)?.enabled {
6792        return Err(SuperviseError::Disabled {
6793            module_id: spec.module_id.clone(),
6794        });
6795    }
6796    let stop_notice = begin_forwarding_drain_with_timeout(
6797        spec,
6798        runtime,
6799        registry,
6800        snapshot,
6801        None,
6802        RouteCloseReason::Restart,
6803        drain_timeout,
6804    )
6805    .await?;
6806
6807    if child.is_some() {
6808        drain_optional_child(
6809            &spec.module_id,
6810            spec.protocol,
6811            stop_notice,
6812            registry,
6813            runtime.forwarding.as_deref(),
6814            snapshot,
6815            &runtime.terminal_ring,
6816            &runtime.spawn_events,
6817            child,
6818            drain_timeout,
6819            ModuleState::Restarting,
6820            Some(true),
6821        )
6822        .await?;
6823    } else {
6824        update_snapshot(snapshot, Some(&spec.module_id), |state| {
6825            state.enabled = true;
6826            state.state = ModuleState::Restarting;
6827            clear_current_process_facts(state);
6828        })?;
6829        release_dead_registration(
6830            registry,
6831            runtime.forwarding.as_deref(),
6832            snapshot,
6833            &spec.module_id,
6834        )
6835        .await?;
6836    }
6837
6838    reset_restart_count(snapshot, &spec.module_id)?;
6839    process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
6840    schedule_respawn(
6841        runtime,
6842        snapshot,
6843        &spec.module_id,
6844        runtime.restart_policy.backoff,
6845        RespawnKind::Spawn,
6846    )
6847}
6848
6849async fn reload_child(
6850    spec: &ModuleSpec,
6851    runtime: &SupervisorRuntimeConfig,
6852    registry: &Registry,
6853    process_liveness: &SupervisorProcessLiveness,
6854    snapshot: &SharedSnapshot,
6855    child: &mut Option<SupervisedChild>,
6856) -> Result<(), SuperviseError> {
6857    // Reload cycles a running module; it must not silently start a disabled one.
6858    if !lock_snapshot(snapshot)?.enabled {
6859        return Err(SuperviseError::Disabled {
6860            module_id: spec.module_id.clone(),
6861        });
6862    }
6863    let stop_notice = begin_forwarding_drain(
6864        spec,
6865        runtime,
6866        registry,
6867        snapshot,
6868        Some(true),
6869        RouteCloseReason::Reload,
6870    )
6871    .await?;
6872
6873    if child.is_some() {
6874        drain_optional_child(
6875            &spec.module_id,
6876            spec.protocol,
6877            stop_notice,
6878            registry,
6879            runtime.forwarding.as_deref(),
6880            snapshot,
6881            &runtime.terminal_ring,
6882            &runtime.spawn_events,
6883            child,
6884            runtime.drain_timeout,
6885            ModuleState::Restarting,
6886            Some(true),
6887        )
6888        .await?;
6889    } else {
6890        update_snapshot(snapshot, Some(&spec.module_id), |state| {
6891            state.enabled = true;
6892            state.state = ModuleState::Restarting;
6893            clear_current_process_facts(state);
6894        })?;
6895        release_dead_registration(
6896            registry,
6897            runtime.forwarding.as_deref(),
6898            snapshot,
6899            &spec.module_id,
6900        )
6901        .await?;
6902    }
6903
6904    reset_restart_count(snapshot, &spec.module_id)?;
6905    process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
6906    schedule_respawn(
6907        runtime,
6908        snapshot,
6909        &spec.module_id,
6910        runtime.restart_policy.backoff,
6911        RespawnKind::Reload,
6912    )
6913}
6914
6915async fn finish_reload_child(
6916    spec: &ModuleSpec,
6917    runtime: &SupervisorRuntimeConfig,
6918    registry: &Registry,
6919    process_liveness: &SupervisorProcessLiveness,
6920    snapshot: &SharedSnapshot,
6921    child: &mut Option<SupervisedChild>,
6922) -> Result<(), SuperviseError> {
6923    process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
6924    let next_child = match spawn_and_mark_running(spec, runtime, snapshot) {
6925        Ok(next_child) => next_child,
6926        Err(err) => {
6927            return handle_reload_spawn_failure(
6928                spec,
6929                runtime,
6930                process_liveness,
6931                snapshot,
6932                child,
6933                format!("new child failed to spawn: {err}"),
6934            )
6935            .await;
6936        }
6937    };
6938    *child = Some(next_child);
6939
6940    let wait_outcome = {
6941        let active_child = child.as_mut().expect("new reload child was just stored");
6942        wait_for_registration_after_reload(
6943            registry,
6944            &spec.module_id,
6945            snapshot,
6946            active_child,
6947            REGISTRY_RELEASE_TIMEOUT,
6948        )
6949        .await?
6950    };
6951
6952    match wait_outcome {
6953        RegistrationWaitOutcome::Registered => {
6954            debug!(module_id = %spec.module_id, "supervised module reloaded and registered");
6955            Ok(())
6956        }
6957        RegistrationWaitOutcome::Exited(exit_report) => {
6958            if let Some(active_child) = child.as_mut() {
6959                active_child.drain_stderr(&spec.module_id).await;
6960            }
6961            // Keep the reaped child's roster guard until its terminal is written.
6962            // Shutdown waits on that guard, not on the child Option used for respawn.
6963            let mut exited_child = child.take().expect("exited reload child is still stored");
6964            #[cfg(test)]
6965            if let Some(gate) = &runtime.test_reload_exit_record_gate {
6966                gate.reached.notify_one();
6967                gate.resume.notified().await;
6968            }
6969            let result = handle_reload_child_registration_failure(
6970                spec,
6971                runtime,
6972                registry,
6973                process_liveness,
6974                snapshot,
6975                child,
6976                ReloadRegistrationFailure {
6977                    exit_report: registration_failure_exit_report(exit_report),
6978                    reason: exited_child
6979                        .spawn_failure
6980                        .clone()
6981                        .unwrap_or_else(|| "new child exited before registering".to_string()),
6982                },
6983            )
6984            .await;
6985            exited_child.release_roster();
6986            result
6987        }
6988        RegistrationWaitOutcome::TimedOut => {
6989            let mut timed_out_child = child
6990                .take()
6991                .expect("timed-out reload child is still running");
6992            timed_out_child
6993                .start_kill()
6994                .map_err(|source| SuperviseError::Kill {
6995                    module_id: spec.module_id.clone(),
6996                    source,
6997                })?;
6998            let status = timed_out_child
6999                .wait()
7000                .await
7001                .map_err(|source| SuperviseError::Wait {
7002                    module_id: spec.module_id.clone(),
7003                    source,
7004                })?;
7005            timed_out_child.drain_stderr(&spec.module_id).await;
7006            handle_reload_child_registration_failure(
7007                spec,
7008                runtime,
7009                registry,
7010                process_liveness,
7011                snapshot,
7012                child,
7013                ReloadRegistrationFailure {
7014                    exit_report: registration_failure_exit_report(classify_reaped_child_exit(
7015                        snapshot,
7016                        &timed_out_child,
7017                        &status,
7018                    )),
7019                    reason: format!(
7020                        "new child did not register within {:?}",
7021                        REGISTRY_RELEASE_TIMEOUT
7022                    ),
7023                },
7024            )
7025            .await
7026        }
7027    }
7028}
7029
7030async fn set_child_enabled(
7031    spec: &ModuleSpec,
7032    runtime: &SupervisorRuntimeConfig,
7033    registry: &Registry,
7034    process_liveness: &SupervisorProcessLiveness,
7035    snapshot: &SharedSnapshot,
7036    child: &mut Option<SupervisedChild>,
7037    enabled: bool,
7038) -> Result<bool, SuperviseError> {
7039    let (current_enabled, current_state, respawn_pending) = {
7040        let state = lock_snapshot(snapshot)?;
7041        (state.enabled, state.state, state.respawn_pending)
7042    };
7043    // `start` (enable on an already-enabled module) heals TERMINAL states instead
7044    // of no-op'ing: a module whose restart budget exhausted (Failed) or that exited
7045    // clean (Stopped) has no live process and no other in-band recovery — the
7046    // operator's start is the explicit recovery act and resets the budget. Without
7047    // this arm the only revival was subc-probe --supervisor-restart in a terminal,
7048    // which the 2026-07-14 aft outage proved is a trap when the failed module is
7049    // the one providing every agent's shell.
7050    let revive_terminal = enabled
7051        && current_enabled
7052        && child.is_none()
7053        && (matches!(current_state, ModuleState::Failed | ModuleState::Stopped)
7054            || (current_state == ModuleState::Restarting && !respawn_pending));
7055    if current_enabled == enabled && !revive_terminal {
7056        return Ok(false);
7057    }
7058
7059    if enabled {
7060        update_snapshot(snapshot, Some(&spec.module_id), |state| {
7061            state.enabled = true;
7062            state.state = ModuleState::Starting;
7063            clear_current_process_facts(state);
7064        })?;
7065        #[cfg(test)]
7066        if runtime.test_seed_stale_facts_before_enable_spawn {
7067            update_snapshot(snapshot, Some(&spec.module_id), |state| {
7068                state.process_alive = true;
7069                state.pid = Some(41);
7070                state.spawned_at_ms = Some(42);
7071                state.spawned_from = Some(PathBuf::from("/spawned/module"));
7072                state.spawned_file_identity = Some(SpawnedFileIdentity {
7073                    device: 43,
7074                    inode: 44,
7075                    #[cfg(windows)]
7076                    windows_image: None,
7077                });
7078            })?;
7079        }
7080        release_dead_registration(
7081            registry,
7082            runtime.forwarding.as_deref(),
7083            snapshot,
7084            &spec.module_id,
7085        )
7086        .await?;
7087        reset_restart_count(snapshot, &spec.module_id)?;
7088        process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
7089        let next_child = match spawn_and_mark_running(spec, runtime, snapshot) {
7090            Ok(next_child) => next_child,
7091            Err(err) => {
7092                if let Err(state_err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
7093                    state.state = ModuleState::Failed;
7094                    clear_current_process_facts(state);
7095                }) {
7096                    error!(module_id = %spec.module_id, error = %state_err, "failed to record enable spawn failure");
7097                }
7098                process_liveness.untrack_if_current(&spec.module_id, snapshot);
7099                return Err(err);
7100            }
7101        };
7102        *child = Some(next_child);
7103        debug!(module_id = %spec.module_id, "supervised module enabled");
7104        Ok(true)
7105    } else {
7106        let stop_notice = begin_forwarding_drain_if_configured(
7107            spec,
7108            runtime,
7109            registry,
7110            snapshot,
7111            Some(false),
7112            RouteCloseReason::Disable,
7113        )
7114        .await?;
7115        drain_optional_child(
7116            &spec.module_id,
7117            spec.protocol,
7118            stop_notice,
7119            registry,
7120            runtime.forwarding.as_deref(),
7121            snapshot,
7122            &runtime.terminal_ring,
7123            &runtime.spawn_events,
7124            child,
7125            runtime.drain_timeout,
7126            ModuleState::Disabled,
7127            Some(false),
7128        )
7129        .await?;
7130        debug!(module_id = %spec.module_id, "supervised module disabled");
7131        Ok(true)
7132    }
7133}
7134
7135#[allow(clippy::too_many_arguments)]
7136async fn on_child_exit(
7137    spec: &ModuleSpec,
7138    policy: RestartPolicy,
7139    registry: &Registry,
7140    snapshot: &SharedSnapshot,
7141    terminal_ring: &Arc<Mutex<TerminalRing>>,
7142    spawn_events: &SpawnEventFeed,
7143    roster: &ChildRoster,
7144    exit_report: ExitReport,
7145) -> NextAction {
7146    // Once the daemon has begun shutting down, no exit is a crash to recover
7147    // from: the module is exiting because the daemon is going away (EOF on its
7148    // connection, or a service manager signalling the whole cgroup). Record it
7149    // as such and never schedule a respawn, which would only start a process
7150    // for the shutdown to end again.
7151    if roster.is_closed() {
7152        return on_child_exit_during_daemon_shutdown(
7153            spec,
7154            registry,
7155            snapshot,
7156            terminal_ring,
7157            spawn_events,
7158            exit_report,
7159        )
7160        .await;
7161    }
7162    // Every stop the supervisor itself asks for (operator stop, disable,
7163    // restart, reload, swap, a health restart, a drain that runs out of budget)
7164    // takes the child out of the supervise loop and reaps it in
7165    // `drain_child_to_state`, and daemon shutdown is handled above. So an exit
7166    // that reaches this point was not requested by the daemon.
7167    //
7168    // For a subc-wire module a clean exit is still a stop: those modules are
7169    // written to re-raise SIGTERM, so a stray outside signal already reads as a
7170    // crash, and exiting 0 is a deliberate choice the module made. A
7171    // `protocol: "none"` module is a stock program we cannot change, and many
7172    // of them (nats-server among them) exit 0 on SIGTERM. Treating that as a
7173    // stop would leave the module down for good after any stray signal, so it
7174    // goes through the crash path instead: it spends restart budget, respawns
7175    // with the crash backoff, and ends `failed` when the budget runs out.
7176    let unrequested_clean_exit_of_protocol_none = exit_report.kind == ExitKind::Clean
7177        && running_protocol(spec, snapshot) == ModuleProtocol::None;
7178    match exit_report.kind {
7179        ExitKind::Clean if !unrequested_clean_exit_of_protocol_none => {
7180            info!(
7181                module_id = %spec.module_id,
7182                exit_code = ?exit_report.code,
7183                exit_signal = ?exit_report.signal,
7184                "supervised module exited cleanly"
7185            );
7186            if let Err(err) = publish_after_terminal_record(
7187                &spec.module_id,
7188                || {
7189                    record_terminal(
7190                        &spec.module_id,
7191                        terminal_ring,
7192                        spawn_events,
7193                        &exit_report,
7194                        TerminalDisposition::Stopped,
7195                    )
7196                },
7197                || {
7198                    update_snapshot(snapshot, Some(&spec.module_id), |state| {
7199                        state.state = ModuleState::Stopped;
7200                        clear_current_process_facts(state);
7201                        state.last_exit = Some(exit_report.clone());
7202                    })
7203                },
7204            ) {
7205                error!(module_id = %spec.module_id, error = %err, "failed to publish clean module exit");
7206            }
7207            let registration_released = match wait_for_registration_release(
7208                registry,
7209                &spec.module_id,
7210                REGISTRY_RELEASE_TIMEOUT,
7211            )
7212            .await
7213            {
7214                Ok(()) => true,
7215                Err(err) => {
7216                    warn!(module_id = %spec.module_id, error = %err, "registration still active after clean exit");
7217                    false
7218                }
7219            };
7220            NextAction::Stop {
7221                registration_released,
7222            }
7223        }
7224        ExitKind::Clean | ExitKind::Crash => {
7225            if unrequested_clean_exit_of_protocol_none {
7226                warn!(
7227                    module_id = %spec.module_id,
7228                    exit_code = ?exit_report.code,
7229                    exit_signal = ?exit_report.signal,
7230                    "protocol-none module exited cleanly without a stop request; handling it as a crash"
7231                );
7232            } else {
7233                warn!(
7234                    module_id = %spec.module_id,
7235                    exit_code = ?exit_report.code,
7236                    exit_signal = ?exit_report.signal,
7237                    "supervised module exited abnormally (crash)"
7238                );
7239            }
7240            let mut restart_schedule = None;
7241            let mut disposition = TerminalDisposition::Disabled;
7242            let mut final_state = ModuleState::Disabled;
7243            // Set only when the budget is what stopped the module, so the
7244            // terminal record says which limit was hit rather than leaving
7245            // `failed` to be read as "crashed once, badly".
7246            let mut disposition_detail = lock_snapshot(snapshot)
7247                .ok()
7248                .and_then(|mut state| state.spawn_failure.take());
7249            let now = Instant::now();
7250            if let Err(err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
7251                if state.enabled {
7252                    if let Some(schedule) = state.next_crash_restart(&policy, now) {
7253                        final_state = ModuleState::Restarting;
7254                        restart_schedule = Some(schedule);
7255                        disposition = TerminalDisposition::Restarting;
7256                    } else {
7257                        final_state = ModuleState::Failed;
7258                        disposition = TerminalDisposition::Failed;
7259                        let budget = policy.budget_exhausted_detail();
7260                        disposition_detail =
7261                            Some(disposition_detail.take().map_or_else(
7262                                || budget.clone(),
7263                                |cause| format!("{cause}; {budget}"),
7264                            ));
7265                    }
7266                } else {
7267                    disposition = TerminalDisposition::Disabled;
7268                }
7269            }) {
7270                error!(module_id = %spec.module_id, error = %err, "failed to record crashed module exit");
7271                return NextAction::Stop {
7272                    registration_released: false,
7273                };
7274            }
7275            if disposition == TerminalDisposition::Failed {
7276                // The window is in the message, not only in the fields: this line
7277                // is read in a scrollback where a bare `max_restarts=3` reads as a
7278                // lifetime cap and sends the operator looking for three crashes
7279                // that never happened together.
7280                error!(
7281                    module_id = %spec.module_id,
7282                    max_restarts = policy.max_restarts,
7283                    window_secs = policy.window.as_secs(),
7284                    "module stopped: {}",
7285                    policy.budget_exhausted_detail()
7286                );
7287            }
7288            if let Err(err) = publish_after_terminal_record(
7289                &spec.module_id,
7290                || {
7291                    record_terminal_with_detail(
7292                        &spec.module_id,
7293                        terminal_ring,
7294                        spawn_events,
7295                        &exit_report,
7296                        disposition,
7297                        disposition_detail,
7298                    )
7299                },
7300                || {
7301                    update_snapshot(snapshot, Some(&spec.module_id), |state| {
7302                        state.state = final_state;
7303                        clear_current_process_facts(state);
7304                        state.last_exit = Some(exit_report.clone());
7305                    })
7306                },
7307            ) {
7308                error!(module_id = %spec.module_id, error = %err, "failed to publish crashed module exit");
7309            }
7310
7311            if let Some(schedule) = restart_schedule {
7312                NextAction::Restart {
7313                    schedule: Some(schedule),
7314                }
7315            } else {
7316                let registration_released = match wait_for_registration_release(
7317                    registry,
7318                    &spec.module_id,
7319                    REGISTRY_RELEASE_TIMEOUT,
7320                )
7321                .await
7322                {
7323                    Ok(()) => true,
7324                    Err(err) => {
7325                        warn!(module_id = %spec.module_id, error = %err, "registration still active after failed module");
7326                        false
7327                    }
7328                };
7329                NextAction::Stop {
7330                    registration_released,
7331                }
7332            }
7333        }
7334        ExitKind::DeliberateSeverance => {
7335            warn!(
7336                module_id = %spec.module_id,
7337                exit_code = ?exit_report.code,
7338                exit_signal = ?exit_report.signal,
7339                "supervised module exited after deliberate connection severance"
7340            );
7341            let should_restart = match lock_snapshot(snapshot) {
7342                Ok(state) => state.enabled,
7343                Err(err) => {
7344                    error!(module_id = %spec.module_id, error = %err, "failed to read deliberately severed module state");
7345                    return NextAction::Stop {
7346                        registration_released: false,
7347                    };
7348                }
7349            };
7350            let final_state = if should_restart {
7351                ModuleState::Restarting
7352            } else {
7353                ModuleState::Disabled
7354            };
7355            if let Err(err) = publish_after_terminal_record(
7356                &spec.module_id,
7357                || {
7358                    record_terminal(
7359                        &spec.module_id,
7360                        terminal_ring,
7361                        spawn_events,
7362                        &exit_report,
7363                        terminal_disposition(final_state),
7364                    )
7365                },
7366                || {
7367                    update_snapshot(snapshot, Some(&spec.module_id), |state| {
7368                        state.state = final_state;
7369                        clear_current_process_facts(state);
7370                        state.last_exit = Some(exit_report.clone());
7371                        state.lifetime_restarts += 1;
7372                    })
7373                },
7374            ) {
7375                error!(module_id = %spec.module_id, error = %err, "failed to publish deliberately severed module exit");
7376                return NextAction::Stop {
7377                    registration_released: false,
7378                };
7379            }
7380
7381            if should_restart {
7382                NextAction::Restart { schedule: None }
7383            } else {
7384                let registration_released = match wait_for_registration_release(
7385                    registry,
7386                    &spec.module_id,
7387                    REGISTRY_RELEASE_TIMEOUT,
7388                )
7389                .await
7390                {
7391                    Ok(()) => true,
7392                    Err(err) => {
7393                        warn!(module_id = %spec.module_id, error = %err, "registration still active after deliberately severed module exit");
7394                        false
7395                    }
7396                };
7397                NextAction::Stop {
7398                    registration_released,
7399                }
7400            }
7401        }
7402    }
7403}
7404
7405async fn on_child_exit_during_daemon_shutdown(
7406    spec: &ModuleSpec,
7407    registry: &Registry,
7408    snapshot: &SharedSnapshot,
7409    terminal_ring: &Arc<Mutex<TerminalRing>>,
7410    spawn_events: &SpawnEventFeed,
7411    exit_report: ExitReport,
7412) -> NextAction {
7413    info!(
7414        module_id = %spec.module_id,
7415        exit_code = ?exit_report.code,
7416        exit_signal = ?exit_report.signal,
7417        exit_kind = ?exit_report.kind,
7418        "supervised module exited during daemon shutdown; not restarting it"
7419    );
7420    if let Err(err) = publish_after_terminal_record(
7421        &spec.module_id,
7422        || {
7423            record_terminal(
7424                &spec.module_id,
7425                terminal_ring,
7426                spawn_events,
7427                &exit_report,
7428                TerminalDisposition::DaemonShutdown,
7429            )
7430        },
7431        || {
7432            update_snapshot(snapshot, Some(&spec.module_id), |state| {
7433                state.state = ModuleState::Stopped;
7434                clear_current_process_facts(state);
7435                state.last_exit = Some(exit_report.clone());
7436            })
7437        },
7438    ) {
7439        error!(module_id = %spec.module_id, error = %err, "failed to publish module exit during daemon shutdown");
7440    }
7441    let registration_released =
7442        wait_for_registration_release(registry, &spec.module_id, REGISTRY_RELEASE_TIMEOUT)
7443            .await
7444            .is_ok();
7445    NextAction::Stop {
7446        registration_released,
7447    }
7448}
7449
7450// Keep the status lock out of terminal I/O. Readers may observe the previous
7451// state while recording is in progress, but never a completed exit without its
7452// history. Journal errors are logged by the journal and retained in the ring;
7453// a recording panic still publishes the final state before resuming the unwind.
7454fn publish_after_terminal_record<T>(
7455    module_id: &str,
7456    record: impl FnOnce(),
7457    publish: impl FnOnce() -> T,
7458) -> T {
7459    let recorded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(record));
7460    let published = publish();
7461    if let Err(panic) = recorded {
7462        error!(
7463            module_id,
7464            "terminal recording panicked; published final state before resuming unwind"
7465        );
7466        std::panic::resume_unwind(panic);
7467    }
7468    published
7469}
7470
7471fn fail_after_wait_error(
7472    module_id: &str,
7473    snapshot: &SharedSnapshot,
7474    terminal_ring: &Arc<Mutex<TerminalRing>>,
7475    spawn_events: &SpawnEventFeed,
7476    detail: String,
7477) {
7478    let report = wait_error_exit_report();
7479    publish_after_terminal_record(
7480        module_id,
7481        || {
7482            record_terminal_with_detail(
7483                module_id,
7484                terminal_ring,
7485                spawn_events,
7486                &report,
7487                TerminalDisposition::Failed,
7488                Some(detail),
7489            )
7490        },
7491        || fail_snapshot(snapshot, Some(module_id), Some(report.clone())),
7492    );
7493}
7494
7495#[cfg(test)]
7496fn record_wait_error_terminal(
7497    module_id: &str,
7498    terminal_ring: &Arc<Mutex<TerminalRing>>,
7499    spawn_events: &SpawnEventFeed,
7500) {
7501    record_terminal(
7502        module_id,
7503        terminal_ring,
7504        spawn_events,
7505        &wait_error_exit_report(),
7506        TerminalDisposition::Failed,
7507    );
7508}
7509
7510fn record_terminal(
7511    module_id: &str,
7512    terminal_ring: &Arc<Mutex<TerminalRing>>,
7513    spawn_events: &SpawnEventFeed,
7514    exit_report: &ExitReport,
7515    disposition: TerminalDisposition,
7516) {
7517    record_terminal_with_detail(
7518        module_id,
7519        terminal_ring,
7520        spawn_events,
7521        exit_report,
7522        disposition,
7523        None,
7524    );
7525}
7526
7527/// The ring lock is held only to capture the read (see
7528/// `TerminalJournal::capture_read`), so this module's exits keep recording
7529/// while the journal files are read. Blocking: it reads files.
7530fn durable_terminal_history_of(
7531    terminal_ring: &Mutex<TerminalRing>,
7532    module_id: &str,
7533) -> subc_control::TerminalHistory {
7534    let read = terminal_ring
7535        .lock()
7536        .unwrap_or_else(|p| p.into_inner())
7537        .capture_durable_history();
7538    read.read(module_id)
7539}
7540
7541fn record_terminal_with_detail(
7542    module_id: &str,
7543    terminal_ring: &Arc<Mutex<TerminalRing>>,
7544    spawn_events: &SpawnEventFeed,
7545    exit_report: &ExitReport,
7546    disposition: TerminalDisposition,
7547    disposition_detail: Option<String>,
7548) {
7549    #[cfg(test)]
7550    terminal_history_tests::before_terminal_record(spawn_events);
7551    spawn_events.emit_exited(module_id, exit_report.code, exit_report.signal);
7552    let record = TerminalRecord {
7553        exit_code: exit_report.code,
7554        exit_signal: exit_report.signal,
7555        at_ms: exit_report.at_ms,
7556        disposition,
7557        exit_kind: exit_report.kind.into(),
7558        disposition_detail,
7559    };
7560    terminal_ring
7561        .lock()
7562        .unwrap_or_else(|poisoned| poisoned.into_inner())
7563        .record_exit(module_id, record);
7564}
7565
7566fn untrack_if_registration_released(
7567    process_liveness: &SupervisorProcessLiveness,
7568    registry: &Registry,
7569    module_id: &str,
7570    snapshot: &SharedSnapshot,
7571) {
7572    match registry.get_module(module_id) {
7573        Ok(None) => process_liveness.untrack_if_current(module_id, snapshot),
7574        Ok(Some(_)) => {}
7575        Err(err) => {
7576            warn!(module_id, error = %err, "could not determine whether supervisor liveness can be untracked");
7577        }
7578    }
7579}
7580
7581/// The child's environment plan: inherit the parent's, drop ambient `CK_LOG`,
7582/// then apply the module's configured entries minus daemon-private capture keys.
7583///
7584/// Separated from `spawn_child` only so it can be asserted without spawning a
7585/// process — a duplicate of this logic in a test would pass while the real one
7586/// drifted, which is the defect class this function exists to avoid.
7587/// The subc-wire half of a spawn: `--subc <connection file>` and the launch
7588/// nonce. A `protocol: "none"` module gets neither, because it cannot use
7589/// either and the argument would stop a stock binary from starting at all.
7590/// `SUBC_MODULE_ID` is set on every path since an unread variable is inert.
7591///
7592/// The plain-spawn form, kept for the tests that assert its plan; spawns go
7593/// through [`apply_wire_spawn_args_for_role`].
7594#[cfg(test)]
7595fn apply_wire_spawn_args(
7596    command: &mut Command,
7597    spec: &ModuleSpec,
7598    connection_file_path: Option<&std::path::Path>,
7599    handle: Option<&SupervisorHandle>,
7600) -> Result<Option<NonceHandoff>, SuperviseError> {
7601    apply_wire_spawn_args_for_role(
7602        command,
7603        spec,
7604        connection_file_path,
7605        handle,
7606        SpawnRole::Plain,
7607    )
7608}
7609
7610/// The read end of a spawn's launch-nonce pipe, prepared by
7611/// [`apply_wire_spawn_args_for_role`] and installed as the child's descriptor 3
7612/// by the last pre-exec step, just before `spawn()`. Windows prepares a named
7613/// server before spawn and authenticates its client after the child exists.
7614#[cfg(unix)]
7615type NonceHandoff = subc_os::LaunchNonceHandoff;
7616#[cfg(windows)]
7617type NonceHandoff = subc_os::LaunchNoncePipeHandoff;
7618#[cfg(not(any(unix, windows)))]
7619type NonceHandoff = std::convert::Infallible;
7620
7621/// Create the launch nonce that a new module process must present when it
7622/// registers or opens a consumer connection, including a replacement process
7623/// started alongside a still-serving module.
7624///
7625/// A plain spawn replaces the module's recorded nonce. A swap candidate records
7626/// a separate candidate token so the still-serving incumbent and its consumers
7627/// keep their nonce. Both records are installed before the process exists, so
7628/// the child's initial HELLO registration cannot arrive ahead of its nonce.
7629///
7630/// On Unix the nonce is delivered only through a pipe. It is written into
7631/// a pipe whose read end the child gets as descriptor 3, named by
7632/// `SUBC_LAUNCH_NONCE_FD=3:<pipe inode>`: unlike the environment, another
7633/// process of the same user cannot read it with `ps eww`. That handoff is
7634/// returned rather than installed here, because installing it replaces
7635/// whatever the child has at descriptor 3 and so must be the last pre-exec
7636/// step. Later in `spawn_child_in_slot`, `apply_cgroup_placement` first registers
7637/// the Linux callback that writes the process into its cgroup. Only after that
7638/// does `handoff.install_last` register the descriptor-3 replacement, so it
7639/// cannot close a descriptor the cgroup callback still needs.
7640/// Windows keeps SUBC_LAUNCH_NONCE as well until every module reads the named pipe.
7641fn apply_wire_spawn_args_for_role(
7642    command: &mut Command,
7643    spec: &ModuleSpec,
7644    connection_file_path: Option<&std::path::Path>,
7645    handle: Option<&SupervisorHandle>,
7646    role: SpawnRole,
7647) -> Result<Option<NonceHandoff>, SuperviseError> {
7648    command.env(SUBC_MODULE_ID_ENV, &spec.module_id);
7649    // SUBC_LAUNCH_NONCE_FD is removed for every child, `protocol: "none"`
7650    // included: a daemon started from a module's process tree inherits it,
7651    // and passing it on would point the child at a descriptor it does not
7652    // have.
7653    command.env_remove(subc_os::LAUNCH_NONCE_FD_ENV);
7654    #[cfg(windows)]
7655    {
7656        command.env_remove(subc_os::LAUNCH_NONCE_PIPE_ENV);
7657        command.env_remove(subc_os::LAUNCH_NONCE_PIPE_FALLBACK_ENV);
7658    }
7659    // Remove inherited or configured copies too: withholding must mean absent.
7660    command.env_remove(SUBC_LAUNCH_NONCE_ENV);
7661    if spec.protocol == ModuleProtocol::None {
7662        return Ok(None);
7663    }
7664    if let Some(connection_file_path) = connection_file_path {
7665        command.arg(SUBC_ARG).arg(connection_file_path);
7666    }
7667
7668    // Every subc-wire spawn receives a fresh one-time launch nonce for consumer
7669    // route.open attestation. Reserved modules additionally use the same nonce
7670    // for HELLO id-squatting protection. A respawn rotates both records.
7671    let nonce = generate_launch_nonce()?;
7672    if let Some(handle) = handle {
7673        match role {
7674            SpawnRole::Plain => {
7675                handle.set_spawn_nonce(&spec.module_id, nonce.clone());
7676                if spec.reserved {
7677                    handle.set_reserved_nonce(&spec.module_id, nonce.clone());
7678                }
7679            }
7680            SpawnRole::SwapCandidate => handle.open_swap(&spec.module_id, nonce.clone()),
7681        }
7682    }
7683    #[cfg(unix)]
7684    let handoff = {
7685        let handoff =
7686            subc_os::LaunchNonceHandoff::new(&nonce).map_err(|source| SuperviseError::Spawn {
7687                program: spec.program.clone(),
7688                source,
7689                cgroup_path: None,
7690            })?;
7691        command.env(subc_os::LAUNCH_NONCE_FD_ENV, handoff.fd_env_value());
7692        Some(handoff)
7693    };
7694    #[cfg(windows)]
7695    let handoff = prepare_windows_nonce_pipe(command, spec, &nonce);
7696    #[cfg(not(any(unix, windows)))]
7697    let handoff = None;
7698    // Older modules still read SUBC_LAUNCH_NONCE to register. Keep that copy
7699    // until the daemon's per-spawn delivery records show that every running
7700    // Windows module has read its secret from the named pipe.
7701    #[cfg(not(unix))]
7702    command.env(SUBC_LAUNCH_NONCE_ENV, nonce);
7703    Ok(handoff)
7704}
7705
7706fn apply_child_env(command: &mut Command, spec: &ModuleSpec) {
7707    command.env_remove(CK_LOG_ENV);
7708    // The spawn role is the supervisor's to set, and only on a swap candidate
7709    // (see `apply_spawn_role`). Removing it here, rather than just not setting
7710    // it, is what makes it absent on a plain spawn: the daemon's own
7711    // environment could carry it, and so could a spec built outside daemon
7712    // config (config refuses it as an `env` key). A module reading it on a
7713    // plain restart would pick the long swap budget and leave callers waiting.
7714    command.env_remove(SUBC_SPAWN_ROLE_ENV);
7715    for (key, value) in &spec.env {
7716        // cortexkit-log currently exposes retention only as a Rust struct, not
7717        // environment names. These values are daemon-private sink metadata and
7718        // must never become a public child-process contract by being inherited.
7719        if matches!(
7720            key.as_str(),
7721            CAPTURE_MAX_FILE_MB_ENV | CAPTURE_KEEP_ENV | CAPTURE_MAX_AGE_DAYS_ENV
7722        ) || key == SUBC_SPAWN_ROLE_ENV
7723        {
7724            continue;
7725        }
7726        command.env(key, value);
7727    }
7728}
7729
7730#[cfg(windows)]
7731fn prepare_windows_nonce_pipe(
7732    command: &mut Command,
7733    spec: &ModuleSpec,
7734    nonce: &str,
7735) -> Option<NonceHandoff> {
7736    match subc_os::LaunchNoncePipeHandoff::new(nonce) {
7737        Ok(handoff) => {
7738            command.env(subc_os::LAUNCH_NONCE_PIPE_ENV, handoff.name());
7739            // Let a failed pipe read use SUBC_LAUNCH_NONCE so the process can
7740            // still register until every module reads the pipe. A reader without
7741            // this variable set to env returns the pipe error instead of using
7742            // an inherited or configured environment copy.
7743            command.env(subc_os::LAUNCH_NONCE_PIPE_FALLBACK_ENV, "env");
7744            Some(handoff)
7745        }
7746        Err(error) => {
7747            warn!(module_id = %spec.module_id, %error, "could not prepare launch nonce pipe; retaining the environment handoff");
7748            None
7749        }
7750    }
7751}
7752
7753#[cfg(windows)]
7754fn start_windows_nonce_pipe(
7755    handoff: Option<NonceHandoff>,
7756    pid: u32,
7757    spec: &ModuleSpec,
7758) -> Option<subc_os::LaunchNoncePipeDelivery> {
7759    // Close an unread pipe after REGISTRY_RELEASE_TIMEOUT (one second), the
7760    // same maximum wait used for registration during a reload. Older modules
7761    // may register using SUBC_LAUNCH_NONCE without ever connecting to the pipe.
7762    handoff.and_then(|handoff| match handoff.serve(pid, REGISTRY_RELEASE_TIMEOUT) {
7763        Ok(delivery) => Some(delivery),
7764        Err(error) => {
7765            warn!(module_id = %spec.module_id, %error, "could not serve launch nonce pipe; retaining the environment handoff");
7766            None
7767        }
7768    })
7769}
7770
7771fn observed_launch_nonce_source(snapshot: &SupervisorSnapshot) -> Option<String> {
7772    #[cfg(windows)]
7773    if snapshot.process_alive && snapshot.spawned_protocol == Some(ModuleProtocol::Subc) {
7774        return Some(
7775            if snapshot
7776                .pipe_consumed
7777                .as_ref()
7778                .is_some_and(|consumed| consumed.get().is_some())
7779            {
7780                "pipe"
7781            } else {
7782                "env"
7783            }
7784            .to_owned(),
7785        );
7786    }
7787    let _ = snapshot;
7788    None
7789}
7790
7791/// Which slot a spawn fills: the module's ordinary one, or the candidate slot
7792/// of a blue/green swap.
7793#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7794enum SpawnRole {
7795    Plain,
7796    SwapCandidate,
7797}
7798
7799/// Set the spawn role for a swap candidate. A plain spawn gets nothing here;
7800/// `apply_child_env` has already removed the variable for every spawn.
7801fn apply_spawn_role(command: &mut Command, role: SpawnRole) {
7802    if role == SpawnRole::SwapCandidate {
7803        command.env(SUBC_SPAWN_ROLE_ENV, SPAWN_ROLE_SWAP_CANDIDATE);
7804    }
7805}
7806
7807#[cfg(target_os = "linux")]
7808fn previous_instance_error(spec: &ModuleSpec, pid: u32) -> SuperviseError {
7809    SuperviseError::Spawn {
7810        program: spec.program.clone(),
7811        source: io::Error::new(io::ErrorKind::WouldBlock, format!(
7812            "waiting: previous instance (pid {pid}) is still running but hidden; it cannot be identified"
7813        )),
7814        cgroup_path: None,
7815    }
7816}
7817
7818fn spawn_child(
7819    spec: &ModuleSpec,
7820    connection_file_path: Option<&std::path::Path>,
7821    handle: Option<&SupervisorHandle>,
7822    ring: &Arc<Mutex<StderrRing>>,
7823    capture_logs_dir: Option<&std::path::Path>,
7824    roster: &ChildRoster,
7825    #[cfg(target_os = "linux")] cgroup_placement: Option<&subc_cgroup::Placement>,
7826) -> Result<SupervisedChild, SuperviseError> {
7827    spawn_child_in_slot(
7828        spec,
7829        connection_file_path,
7830        handle,
7831        ring,
7832        capture_logs_dir,
7833        roster,
7834        #[cfg(target_os = "linux")]
7835        cgroup_placement,
7836        SpawnRole::Plain,
7837        false,
7838    )
7839}
7840
7841/// Spawn one process of `spec` into a slot.
7842///
7843/// `alternate_slot` picks the process's cgroup label on Linux (see `swap::cgroup_name`).
7844/// A swap candidate needs a different cgroup from the process it is replacing,
7845/// which is still alive: in the same cgroup the two would be one kill domain,
7846/// and killing a failed candidate could take the incumbent with it.
7847///
7848/// The stderr capture file is `<module_id>.stderr.log` for every process of
7849/// the module, whichever slot it is in, because that is the one file
7850/// `ck module logs` reads. During a swap's overlap both processes append to it;
7851/// the daemon writes whole lines, so the two interleave by line, which is also
7852/// the merged view an operator wants while a swap runs.
7853#[allow(clippy::too_many_arguments)]
7854fn spawn_child_in_slot(
7855    spec: &ModuleSpec,
7856    connection_file_path: Option<&std::path::Path>,
7857    handle: Option<&SupervisorHandle>,
7858    ring: &Arc<Mutex<StderrRing>>,
7859    capture_logs_dir: Option<&std::path::Path>,
7860    roster: &ChildRoster,
7861    #[cfg(target_os = "linux")] cgroup_placement: Option<&subc_cgroup::Placement>,
7862    role: SpawnRole,
7863    alternate_slot: bool,
7864) -> Result<SupervisedChild, SuperviseError> {
7865    #[cfg(target_os = "linux")]
7866    if let Some(pid) = roster.previous_instance_pid(&spec.module_id) {
7867        return Err(previous_instance_error(spec, pid));
7868    }
7869    if roster.is_closed() {
7870        return Err(SuperviseError::Spawn {
7871            program: spec.program.clone(),
7872            source: io::Error::other("the daemon is shutting down; not starting a new process"),
7873            cgroup_path: None,
7874        });
7875    }
7876    #[cfg(target_os = "linux")]
7877    let cgroup_name = {
7878        // Slot names alone are not kill domains: a retired incumbent may still
7879        // be draining when a later enable/restart spawns into the same slot.
7880        // Decimal entropy keeps the suffix unambiguous; Placement performs
7881        // the module-id escaping and constructs the filesystem path.
7882        if cgroup_placement.is_none() {
7883            swap::cgroup_name(&spec.module_id, alternate_slot)
7884        } else {
7885            let nonce = generate_launch_nonce()?;
7886            let suffix = u128::from_str_radix(&nonce[..32], 16).expect("hex launch nonce");
7887            // Leave room for byte escaping and the suffix under NAME_MAX. The
7888            // label is only for humans; the nonce identifies the kill domain.
7889            let mut end = spec.module_id.len().min(64);
7890            while !spec.module_id.is_char_boundary(end) {
7891                end -= 1;
7892            }
7893            format!(
7894                "{}_{suffix}",
7895                swap::cgroup_name(&spec.module_id[..end], alternate_slot)
7896            )
7897        }
7898    };
7899    #[cfg(not(target_os = "linux"))]
7900    let _ = alternate_slot;
7901    #[cfg(target_os = "macos")]
7902    let (mut command, privacy_exec, exec_ack) = privacy_command(spec, roster)?;
7903    #[cfg(windows)]
7904    let image_capture = windows_spawn_capture(spec).map_err(|source| SuperviseError::Spawn {
7905        program: spec.program.clone(),
7906        source,
7907        cgroup_path: None,
7908    })?;
7909    #[cfg(windows)]
7910    let mut command = Command::new(image_capture.path());
7911    #[cfg(not(any(target_os = "macos", windows)))]
7912    let mut command = Command::new(&spec.program);
7913    command.args(&spec.args);
7914    // AMBIENT `CK_LOG` MUST NOT LEAK INTO AN OTHERWISE UNCONFIGURED MODULE — but
7915    // that is the whole of the intent, so remove that one key rather than the
7916    // environment.
7917    //
7918    // This was `env_clear()` from 0.17.41 until 0.18.3, which achieved the goal
7919    // and took the POSIX environment with it. Modules spawned that way had no
7920    // HOME, XDG_RUNTIME_DIR, TMPDIR or USER, and the consequences ran past
7921    // logging:
7922    //
7923    //   * `connection_file::discover` reads XDG_RUNTIME_DIR and HOME, so with
7924    //     both unset it fell back to the temp dir alone and `ck` could not find
7925    //     a daemon running on the same machine from inside any module's process
7926    //     tree — reporting a path the file has never lived at, which reads as
7927    //     "the daemon did not write its file".
7928    //   * `default_data_home()` with HOME and XDG_DATA_HOME both unset returns
7929    //     the RELATIVE `.local/share`, so a module deriving its own store path
7930    //     resolved it against its own CWD. That is the store-fragmentation
7931    //     defect the daemon already refuses in config (`parse_doc` rejects a
7932    //     relative `storage.data_home`) arriving by derivation instead.
7933    //   * anything a module spawns inherited it: git without ~/.gitconfig,
7934    //     cargo without CARGO_HOME, ssh, python user dirs — all degrading
7935    //     quietly rather than erroring.
7936    //
7937    // Reported by iceteaSA as #104 after deploying 0.18.2, where `ck daemon`
7938    // offered one candidate under /tmp while the file sat in /run/user/1000.
7939    //
7940    // A configured module is unaffected either way: `module_spec()` puts the
7941    // resolved CK_LOG into `spec.env`, which is applied below and therefore
7942    // wins over anything ambient.
7943    apply_child_env(&mut command, spec);
7944    apply_spawn_role(&mut command, role);
7945    let nonce_handoff =
7946        apply_wire_spawn_args_for_role(&mut command, spec, connection_file_path, handle, role)?;
7947
7948    #[cfg(target_os = "linux")]
7949    let cgroup_path = cgroup_placement
7950        .map(|placement| placement.module_path(&cgroup_name))
7951        .transpose()
7952        .map_err(|source| SuperviseError::Cgroup {
7953            module_id: spec.module_id.clone(),
7954            source,
7955        })?;
7956    #[cfg(not(target_os = "linux"))]
7957    let cgroup_path: Option<PathBuf> = None;
7958    #[cfg(target_os = "linux")]
7959    if let Some(path) = &cgroup_path {
7960        if let Err(error) = apply_cgroup_placement(&mut command, spec, path) {
7961            if let Some(placement) = cgroup_placement {
7962                remove_module_cgroup(placement, &cgroup_name);
7963            }
7964            return Err(error);
7965        }
7966    }
7967
7968    let output_sink = if let Some(logs_dir) = capture_logs_dir {
7969        let path = logs_dir.join(format!("{}.stderr.log", spec.module_id));
7970        match ChildOutputSink::open(&path, capture_retention(spec)) {
7971            Ok(sink) => sink,
7972            Err(error) => {
7973                warn!(
7974                    module_id = %spec.module_id,
7975                    path = %path.display(),
7976                    error = %error,
7977                    "could not open child output capture file; forwarding to stderr"
7978                );
7979                ChildOutputSink::Stderr
7980            }
7981        }
7982    } else {
7983        ChildOutputSink::Stderr
7984    };
7985
7986    command.stdout(Stdio::piped());
7987    command.stderr(Stdio::piped());
7988    command.kill_on_drop(true);
7989    // EACH MODULE LEADS ITS OWN PROCESS GROUP (the child calls setpgid(0, 0)
7990    // before exec). In the daemon's group, a service manager that kills the
7991    // job's process group when the daemon exits (launchd's default) killed
7992    // every module at the same moment its control connection closed, so no
7993    // module ever ran its EOF teardown on a daemon stop. Outside that group a
7994    // module is reached only by the daemon: the EOF it sees when its
7995    // connection closes, and the bounded stop in `child_roster` for anything
7996    // still running after that. On Linux this composes with the cgroup
7997    // placement above: that is a pre_exec write to cgroup.procs, std performs
7998    // setpgid in the child before running pre_exec callbacks, and the two
7999    // change independent process attributes.
8000    //
8001    // stdin is /dev/null because a process outside the terminal's foreground
8002    // group is stopped (SIGTTIN) if it reads the terminal, which a daemon run
8003    // by hand would otherwise hand down. Under a service manager stdin is
8004    // already /dev/null.
8005    #[cfg(unix)]
8006    command.process_group(0);
8007    command.stdin(Stdio::null());
8008    // The LAST pre-exec step, after the cgroup placement above: installing the
8009    // nonce at descriptor 3 replaces whatever the child had there, which could
8010    // be the descriptor an earlier step writes through.
8011    #[cfg(unix)]
8012    if let Some(handoff) = nonce_handoff {
8013        handoff.install_last(command.as_std_mut());
8014    }
8015    #[cfg(not(any(unix, windows)))]
8016    let _ = nonce_handoff;
8017
8018    // Containment, step 1 of 3 (issue #109): create the child suspended so it
8019    // cannot run a single instruction -- and therefore cannot spawn a
8020    // grandchild -- before it is in the job. See `contain_spawned_child` for the
8021    // other two steps and why the window matters.
8022    #[cfg(windows)]
8023    subc_jobobject::suspend_on_create_async(&mut command);
8024    let mut child = match command.spawn() {
8025        Ok(child) => child,
8026        Err(source) => {
8027            #[cfg(target_os = "linux")]
8028            if let Some(placement) = cgroup_placement {
8029                remove_module_cgroup(placement, &cgroup_name);
8030            }
8031            return Err(SuperviseError::Spawn {
8032                program: spec.program.clone(),
8033                source,
8034                cgroup_path,
8035            });
8036        }
8037    };
8038    // The parent must close its writer now: the acknowledgement pipe reports EOF
8039    // only when every writer is gone, and the child's copy closes when the
8040    // trampoline replaces itself with the module. Command holds only an integer
8041    // in its pre_exec callback, not another writer.
8042    #[cfg(target_os = "macos")]
8043    drop(exec_ack);
8044
8045    // Bind while the child is still suspended, before job assignment resumes it.
8046    // Keeping the executable handle open across CreateProcessW denies rename
8047    // and deletion, so the file cannot be exchanged during spawn. Bind that
8048    // file ID to the process handle returned by spawn, never a reopened PID.
8049    #[cfg(windows)]
8050    let windows_image =
8051        Arc::new(
8052            image_capture
8053                .bind_tokio(&child)
8054                .map_err(|source| SuperviseError::Spawn {
8055                    program: spec.program.clone(),
8056                    source,
8057                    cgroup_path: None,
8058                })?,
8059        );
8060    // Containment, steps 2 and 3: assign while suspended, then resume.
8061    #[cfg(windows)]
8062    let job = contain_spawned_child(&child, spec)?;
8063    let spawned_at_ms = unix_ms_now();
8064    #[cfg(not(windows))]
8065    let spawned_from = spec.program.clone();
8066    #[cfg(windows)]
8067    let spawned_from = windows_image.path().to_path_buf();
8068    #[cfg(not(windows))]
8069    let spawned_file_identity = spawned_file_identity(&spawned_from);
8070    #[cfg(windows)]
8071    let spawned_file_identity = Some(SpawnedFileIdentity {
8072        device: 0,
8073        inode: 0,
8074        windows_image: Some(Arc::clone(&windows_image)),
8075    });
8076    let pid = child.id().ok_or_else(|| SuperviseError::Spawn {
8077        program: spec.program.clone(),
8078        source: io::Error::other("spawned child exposed no live pid"),
8079        cgroup_path: cgroup_path.clone(),
8080    })?;
8081    #[cfg(windows)]
8082    let nonce_delivery = start_windows_nonce_pipe(nonce_handoff, pid, spec);
8083    #[cfg(not(windows))]
8084    let process_start_time = crate::provenance::process_start_time(pid);
8085    #[cfg(windows)]
8086    let process_start_time = Some(windows_image.start_time());
8087    let process_identity = process_start_time.map(|start_time| ProcessIdentity { pid, start_time });
8088    #[cfg(all(test, target_os = "macos"))]
8089    privacy_exec_boundary_tests::before_image_sample(spec, pid);
8090    // Unix spawn returns after exec's error pipe closes. The kernel image is
8091    // therefore the executable to compare during a future orphan sweep: PATH
8092    // lookup and shebang interpretation may select a different file from the
8093    // configured program. Keep the literal program's identity for provenance,
8094    // but never use it as proof that a recorded pid may be signalled.
8095    #[cfg(not(windows))]
8096    let recorded_image = observe_spawned_image(pid);
8097    // spawn() confirms only the first exec, into the trampoline. Never persist
8098    // the trampoline image; the asynchronous acknowledgement publishes the
8099    // module image once the trampoline has replaced itself with the module.
8100    #[cfg(target_os = "macos")]
8101    let recorded_image = if privacy_exec.is_some() {
8102        None
8103    } else {
8104        recorded_image
8105    };
8106    #[cfg(target_os = "linux")]
8107    let recorded_cgroup_name = cgroup_path.as_ref().map(|_| cgroup_name.clone());
8108    #[cfg(not(target_os = "linux"))]
8109    let recorded_cgroup_name = None;
8110    let roster_guard = roster.admit(
8111        spec.module_id.clone(),
8112        pid,
8113        spec.protocol,
8114        process_start_time,
8115        crate::child_roster::RecordedIdentity {
8116            #[cfg(not(windows))]
8117            start_time: recorded_image.map(|image| image.start_time),
8118            #[cfg(windows)]
8119            start_time: process_start_time,
8120            #[cfg(not(windows))]
8121            executable: recorded_image
8122                .and_then(|image| image.executable)
8123                .map(crate::live_children::ExecutableIdentity::from),
8124            #[cfg(windows)]
8125            executable: Some(crate::live_children::ExecutableIdentity::from(
8126                windows_image.identity(),
8127            )),
8128            cgroup_name: recorded_cgroup_name,
8129            #[cfg(target_os = "linux")]
8130            cgroup_placement: cgroup_placement.cloned(),
8131        },
8132    );
8133    // The check at the top of this function can pass just before daemon
8134    // shutdown begins, and the process is only in the roster from here on.
8135    // The shutdown stop returns as soon as it finds the roster empty, so a
8136    // process admitted after that look would outlive the daemon. The roster
8137    // is closed before the stop first reads it and admission happens under
8138    // the roster's lock, so either the stop sees this process or this check
8139    // sees the roster closed: end the process now rather than start a module
8140    // the daemon is about to stop.
8141    if roster.is_closed() {
8142        // This child was never admitted, so there is no module protocol shutdown to wait for.
8143        #[cfg(target_os = "linux")]
8144        kill_module_cgroup(cgroup_placement, &cgroup_name);
8145        if let Err(error) = child.start_kill() {
8146            debug!(module_id = %spec.module_id, pid, %error, "kill of a process spawned during daemon shutdown failed; it may already have exited");
8147        }
8148        #[cfg(target_os = "linux")]
8149        if let Some(placement) = cgroup_placement {
8150            // This spawn was never admitted, so shutdown has no roster entry
8151            // to await. Do not detach its cleanup: the runtime could exit
8152            // before that task reaps the rejected child and removes its group.
8153            while matches!(child.try_wait(), Ok(None)) {
8154                std::thread::yield_now();
8155            }
8156            if matches!(
8157                subc_cgroup::kill_module(Some(placement), &cgroup_name),
8158                subc_cgroup::KillOutcome::Killed
8159            ) {
8160                if let Ok(path) = placement.module_path(&cgroup_name) {
8161                    while std::fs::read_to_string(path.join("cgroup.events"))
8162                        .ok()
8163                        .is_some_and(|events| events.lines().any(|line| line == "populated 1"))
8164                    {
8165                        std::thread::yield_now();
8166                    }
8167                }
8168            }
8169            remove_module_cgroup(placement, &cgroup_name);
8170        }
8171        drop(roster_guard);
8172        return Err(SuperviseError::Spawn {
8173            program: spec.program.clone(),
8174            source: io::Error::other(
8175                "the daemon began shutting down while this process was starting; ended it",
8176            ),
8177            cgroup_path,
8178        });
8179    }
8180
8181    let stdout_pump = match child.stdout.take() {
8182        Some(stdout) => Some(tokio::spawn(pump_stdout_to(stdout, output_sink.clone()))),
8183        None => {
8184            warn!(
8185                module_id = %spec.module_id,
8186                "spawned child exposed no stdout pipe; file capture will be incomplete"
8187            );
8188            None
8189        }
8190    };
8191    let stderr_pump = match child.stderr.take() {
8192        Some(stderr) => {
8193            let generation = ring
8194                .lock()
8195                .unwrap_or_else(|poisoned| poisoned.into_inner())
8196                .begin_process();
8197            Some(StderrPump {
8198                task: tokio::spawn(pump_stderr_to(
8199                    stderr,
8200                    Arc::clone(ring),
8201                    generation,
8202                    output_sink,
8203                )),
8204                generation,
8205            })
8206        }
8207        None => {
8208            // Spawning succeeded but the pipe did not materialise. Recording it as
8209            // uncaptured keeps the tail honest: the alternative is an empty tail
8210            // that reads as a module which printed nothing.
8211            ring.lock()
8212                .unwrap_or_else(|poisoned| poisoned.into_inner())
8213                .mark_not_captured("stderr pipe was not available on spawn");
8214            warn!(
8215                module_id = %spec.module_id,
8216                "spawned child exposed no stderr pipe; tail will be unavailable"
8217            );
8218            None
8219        }
8220    };
8221
8222    Ok(SupervisedChild {
8223        child,
8224        #[cfg(windows)]
8225        nonce_delivery,
8226        protocol: spec.protocol,
8227        #[cfg(target_os = "linux")]
8228        module_id: cgroup_name,
8229        #[cfg(target_os = "linux")]
8230        cgroup_placement: cgroup_placement.cloned(),
8231        #[cfg(windows)]
8232        job,
8233        stdout_pump,
8234        stderr_pump,
8235        stderr_ring: Arc::clone(ring),
8236        spawned_at_ms,
8237        spawned_from,
8238        spawned_file_identity,
8239        process_start_time,
8240        process_identity,
8241        pid,
8242        roster_guard: Some(roster_guard),
8243        #[cfg(target_os = "macos")]
8244        privacy_exec,
8245        #[cfg(target_os = "macos")]
8246        report_ready: Arc::new(OnceLock::new()),
8247        spawn_failure: None,
8248    })
8249}
8250
8251#[cfg(target_os = "linux")]
8252pub(crate) fn kill_module_cgroup(placement: Option<&subc_cgroup::Placement>, module_id: &str) {
8253    use subc_cgroup::KillOutcome;
8254    match subc_cgroup::kill_module(placement, module_id) {
8255        KillOutcome::Killed => {}
8256        KillOutcome::NotPlaced | KillOutcome::Unsupported => {
8257            debug!(
8258                module_id,
8259                "cgroup tree kill unavailable; using direct-child kill"
8260            );
8261        }
8262        KillOutcome::IoError { path, error } => {
8263            warn!(module_id, path = %path.display(), %error, "cgroup tree kill failed; using direct-child kill");
8264        }
8265    }
8266}
8267
8268/// Contain a freshly spawned Windows child and start it.
8269///
8270/// Steps 2 and 3 of the suspended-create contract: the job is created and the
8271/// child assigned **while it is still suspended** (step 1 is
8272/// `suspend_on_create_async` at the spawn site), then the child is resumed.
8273///
8274/// A child that is never resumed hangs forever holding a pid, so a resume
8275/// failure kills the child and fails the spawn rather than returning a
8276/// `SupervisedChild` that can never run.
8277///
8278/// An assignment failure is NOT fatal: an uncontained module behaves exactly as
8279/// it did before this existed, whereas refusing to start one would be a new
8280/// outage. It is logged at warn because it means a helper process could leak.
8281#[cfg(windows)]
8282fn contain_spawned_child(
8283    child: &Child,
8284    spec: &ModuleSpec,
8285) -> Result<Option<subc_jobobject::JobObject>, SuperviseError> {
8286    let module_id = spec.module_id.as_str();
8287    let Some(pid) = child.id() else {
8288        // The child exited between spawn and here. Its tree, if it made one,
8289        // needs no containment: nothing is left to contain.
8290        warn!(
8291            module_id,
8292            "spawned child had already exited before containment; no job object attached"
8293        );
8294        return Ok(None);
8295    };
8296
8297    let job = match subc_jobobject::JobObject::new() {
8298        Ok(job) => job,
8299        Err(source) => {
8300            warn!(
8301                module_id,
8302                error = %source,
8303                "could not create a job object; this module's helper processes will not be \
8304                 reaped on teardown"
8305            );
8306            // Resume regardless: leaving the child suspended would turn a
8307            // containment gap into a hung module.
8308            resume_suspended_child(pid, spec)?;
8309            return Ok(None);
8310        }
8311    };
8312
8313    if let Err(source) = job.assign(child) {
8314        warn!(
8315            module_id,
8316            error = %source,
8317            "could not assign the child to its job object; this module's helper processes \
8318             will not be reaped on teardown"
8319        );
8320        resume_suspended_child(pid, spec)?;
8321        return Ok(None);
8322    }
8323
8324    resume_suspended_child(pid, spec)?;
8325    Ok(Some(job))
8326}
8327
8328/// Resume a suspended child, killing it if it cannot be started.
8329///
8330/// A suspended process holds a pid and does nothing, so there is no useful
8331/// state to return: the caller gets an error and the spawn fails.
8332#[cfg(windows)]
8333fn resume_suspended_child(pid: u32, spec: &ModuleSpec) -> Result<(), SuperviseError> {
8334    if let Err(source) = subc_jobobject::resume_main_thread(pid) {
8335        // Kill it here rather than leaving a suspended process for the caller
8336        // to notice; `kill_on_drop` would eventually do this, but the module
8337        // would have been reported as running in between.
8338        let _ = std::process::Command::new("taskkill.exe")
8339            .args(["/PID", &pid.to_string(), "/T", "/F"])
8340            .stdin(Stdio::null())
8341            .stdout(Stdio::null())
8342            .stderr(Stdio::null())
8343            .status();
8344        return Err(SuperviseError::Spawn {
8345            program: spec.program.clone(),
8346            source,
8347            cgroup_path: None,
8348        });
8349    }
8350    Ok(())
8351}
8352
8353#[cfg(target_os = "linux")]
8354fn remove_module_cgroup(placement: &subc_cgroup::Placement, module_id: &str) {
8355    match placement.remove_module(module_id) {
8356        Ok(()) => debug!(module_id, "removed module cgroup after process exit"),
8357        Err(error) => warn!(
8358            module_id,
8359            error = %error,
8360            "could not remove module cgroup after process exit; continuing teardown"
8361        ),
8362    }
8363}
8364
8365#[cfg(target_os = "linux")]
8366async fn cleanup_reaped_cgroup(placement: &subc_cgroup::Placement, module_id: &str) {
8367    // Reaping the direct child is not proof its descendants exited. End the
8368    // residual tree and wait for the kernel's population fact before rmdir;
8369    // otherwise a successful parent wait leaks a directory on each restart.
8370    if matches!(
8371        subc_cgroup::kill_module(Some(placement), module_id),
8372        subc_cgroup::KillOutcome::Killed
8373    ) {
8374        if let Ok(path) = placement.module_path(module_id) {
8375            while std::fs::read_to_string(path.join("cgroup.events"))
8376                .ok()
8377                .is_some_and(|events| events.lines().any(|line| line == "populated 1"))
8378            {
8379                sleep(Duration::from_millis(1)).await;
8380            }
8381        }
8382    }
8383    remove_module_cgroup(placement, module_id);
8384}
8385
8386#[cfg(target_os = "linux")]
8387fn apply_cgroup_placement(
8388    command: &mut Command,
8389    spec: &ModuleSpec,
8390    path: &std::path::Path,
8391) -> Result<(), SuperviseError> {
8392    subc_cgroup::apply(command, path).map_err(|source| SuperviseError::Cgroup {
8393        module_id: spec.module_id.clone(),
8394        source,
8395    })
8396}
8397
8398fn capture_retention(spec: &ModuleSpec) -> Retention {
8399    let defaults = Retention::default();
8400    let value = |name: &str| {
8401        spec.env
8402            .iter()
8403            .rev()
8404            .find_map(|(key, value)| (key == name).then_some(value.as_str()))
8405    };
8406    Retention {
8407        max_file_mb: value(CAPTURE_MAX_FILE_MB_ENV)
8408            .and_then(|value| value.parse().ok())
8409            .unwrap_or(defaults.max_file_mb),
8410        keep: value(CAPTURE_KEEP_ENV)
8411            .and_then(|value| value.parse().ok())
8412            .unwrap_or(defaults.keep),
8413        max_age_days: value(CAPTURE_MAX_AGE_DAYS_ENV)
8414            .and_then(|value| value.parse().ok())
8415            .unwrap_or(defaults.max_age_days),
8416    }
8417}
8418
8419/// A fresh 256-bit CSPRNG launch nonce, lowercase hex. Used to bind a reserved
8420/// module's registration to the exact process the supervisor spawned.
8421fn generate_launch_nonce() -> Result<String, SuperviseError> {
8422    let mut bytes = [0u8; 32];
8423    getrandom::getrandom(&mut bytes).map_err(|source| SuperviseError::LaunchNonce {
8424        reason: source.to_string(),
8425    })?;
8426    let mut hex = String::with_capacity(64);
8427    for b in bytes {
8428        use std::fmt::Write;
8429        let _ = write!(hex, "{b:02x}");
8430    }
8431    Ok(hex)
8432}
8433
8434/// Constant-time byte comparison so a reserved-nonce mismatch leaks no timing
8435/// signal about how many leading bytes matched.
8436fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
8437    if a.len() != b.len() {
8438        return false;
8439    }
8440    let mut diff = 0u8;
8441    for (x, y) in a.iter().zip(b.iter()) {
8442        diff |= x ^ y;
8443    }
8444    diff == 0
8445}
8446
8447/// The kernel's image after an acknowledged exec, shared by ordinary launches
8448/// and privacy trampolines. PATH and shebang interpretation are kernel facts,
8449/// not identities inferred from a configured pathname.
8450#[cfg(not(windows))]
8451fn observe_spawned_image(pid: u32) -> Option<subc_os::Observation> {
8452    subc_os::Process::open(pid)
8453        .ok()
8454        .flatten()
8455        .and_then(|process| process.observe())
8456}
8457
8458/// Resolve once and spawn that exact path, so PATH lookup cannot choose a
8459/// different executable after the no-delete capture was taken.
8460#[cfg(windows)]
8461fn windows_spawn_capture(spec: &ModuleSpec) -> io::Result<subc_os::ExecutableCapture> {
8462    let mut program = spec.program.clone();
8463    if program.is_absolute() || program.components().count() > 1 {
8464        // An explicit path can name a PE image with no extension. Try that
8465        // exact file first, as Command does, before falling back to .exe.
8466        match subc_os::ExecutableCapture::open(&program) {
8467            Ok(capture) => return Ok(capture),
8468            Err(error)
8469                if error.kind() == io::ErrorKind::NotFound && program.extension().is_none() => {}
8470            Err(error) => return Err(error),
8471        }
8472        program.set_extension("exe");
8473        return subc_os::ExecutableCapture::open(&program);
8474    }
8475    if program.extension().is_none() {
8476        program.set_extension("exe");
8477    }
8478    let custom_path = spec
8479        .env
8480        .iter()
8481        .rev()
8482        .find(|(key, _)| key.eq_ignore_ascii_case("PATH"));
8483    let mut directories = Vec::new();
8484    // These are Windows' default executable search locations. A configured
8485    // PATH still overrides the inherited PATH, not the system directories.
8486    if let Some(parent) = std::env::current_exe()?.parent() {
8487        directories.push(parent.to_path_buf());
8488    }
8489    directories.push(std::env::current_dir()?);
8490    if let Some(windows) = std::env::var_os("SystemRoot") {
8491        let windows = PathBuf::from(windows);
8492        directories.push(windows.join("System32"));
8493        directories.push(windows.clone());
8494    }
8495    let path = custom_path
8496        .map(|(_, value)| std::ffi::OsString::from(value))
8497        .or_else(|| std::env::var_os("PATH"));
8498    if let Some(path) = path {
8499        directories.extend(std::env::split_paths(&path));
8500    }
8501    for directory in directories {
8502        match subc_os::ExecutableCapture::open(&directory.join(&program)) {
8503            Ok(capture) => return Ok(capture),
8504            Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
8505            Err(error) => return Err(error),
8506        }
8507    }
8508    Err(io::Error::new(
8509        io::ErrorKind::NotFound,
8510        "resolved executable not found",
8511    ))
8512}
8513
8514fn spawn_and_mark_running(
8515    spec: &ModuleSpec,
8516    runtime: &SupervisorRuntimeConfig,
8517    snapshot: &SharedSnapshot,
8518) -> Result<SupervisedChild, SuperviseError> {
8519    let child = spawn_child(
8520        spec,
8521        runtime.connection_file_path.as_deref(),
8522        runtime.supervisor_handle.as_ref(),
8523        &runtime.stderr_ring,
8524        runtime.capture_logs_dir.as_deref(),
8525        &runtime.child_roster,
8526        #[cfg(target_os = "linux")]
8527        runtime.cgroup_placement.as_ref(),
8528    )?;
8529    set_running(snapshot, &child, &spec.module_id, &runtime.spawn_events)?;
8530    Ok(child)
8531}
8532
8533enum RegistrationWaitOutcome {
8534    Registered,
8535    Exited(ExitReport),
8536    TimedOut,
8537}
8538
8539struct ReloadRegistrationFailure {
8540    exit_report: ExitReport,
8541    reason: String,
8542}
8543
8544#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8545enum BusyGaugeObservation {
8546    Quiescent,
8547    Busy,
8548    Omitted,
8549}
8550
8551fn busy_gauge_observation(metrics: Option<&Value>, gauges: &[String]) -> BusyGaugeObservation {
8552    let Some(metrics) = metrics.and_then(Value::as_object) else {
8553        return BusyGaugeObservation::Omitted;
8554    };
8555    let mut sum = 0u128;
8556    for gauge in gauges {
8557        let Some(value) = metrics.get(gauge) else {
8558            return BusyGaugeObservation::Omitted;
8559        };
8560        let Some(value) = value.as_u64() else {
8561            return BusyGaugeObservation::Busy;
8562        };
8563        sum = sum.saturating_add(u128::from(value));
8564    }
8565    if sum == 0 {
8566        BusyGaugeObservation::Quiescent
8567    } else {
8568        BusyGaugeObservation::Busy
8569    }
8570}
8571
8572fn declared_busy_gauges(
8573    registry: &Registry,
8574    module_id: &str,
8575) -> Result<Vec<String>, SuperviseError> {
8576    busy_gauges_of(
8577        registry
8578            .get_module(module_id)
8579            .map_err(SuperviseError::Registry)?,
8580    )
8581}
8582
8583/// [`declared_busy_gauges`] for the registration a connection holds, in any
8584/// slot: after cutover the incumbent is no longer the id's active
8585/// registration, and its own manifest is the one that names its gauges.
8586fn declared_busy_gauges_for_connection(
8587    registry: &Registry,
8588    connection_id: ConnectionId,
8589) -> Result<Vec<String>, SuperviseError> {
8590    busy_gauges_of(
8591        registry
8592            .get_module_by_connection(connection_id)
8593            .map_err(SuperviseError::Registry)?,
8594    )
8595}
8596
8597fn busy_gauges_of(
8598    registration: Option<crate::registry::ModuleRegistration>,
8599) -> Result<Vec<String>, SuperviseError> {
8600    let Some(registration) = registration else {
8601        return Ok(Vec::new());
8602    };
8603    let Some(self_signals) = registration.manifest.self_signals else {
8604        return Ok(Vec::new());
8605    };
8606
8607    let mut gauges = Vec::new();
8608    for declaration in self_signals {
8609        if declaration.kind != SelfSignalKind::Busy {
8610            continue;
8611        }
8612        match declaration.anchored_to {
8613            SignalAnchor::HealthGauges { gauges: declared } if !declared.is_empty() => {
8614                gauges.extend(declared)
8615            }
8616            _ => {
8617                // An invalid Busy anchor is fail-safe: the empty name cannot be
8618                // present in a conforming health report, so this drain stays busy.
8619                gauges.push(String::new());
8620            }
8621        }
8622    }
8623    Ok(gauges)
8624}
8625
8626/// Wait for `endpoint` to have nothing in flight and, when the module declares
8627/// busy gauges, for a health probe to report them quiet. The probe is addressed
8628/// by `scope`: a swap's superseded incumbent must be asked about its own
8629/// gauges, and by module id the probe would reach the promoted candidate.
8630async fn wait_for_forwarding_quiescence(
8631    forwarding: &ForwardingTable,
8632    module_id: &str,
8633    runtime: &SupervisorRuntimeConfig,
8634    endpoint: crate::ModuleEndpointId,
8635    deadline: Instant,
8636    busy_gauges: &[String],
8637    scope: DrainScope,
8638) -> Result<bool, SuperviseError> {
8639    let mut gauges_quiescent = busy_gauges.is_empty();
8640    let mut next_probe_at = Instant::now();
8641    let mut omission_counted = false;
8642
8643    loop {
8644        let now = Instant::now();
8645        if !busy_gauges.is_empty() && now >= next_probe_at && now < deadline {
8646            let report = match scope {
8647                DrainScope::Active => probe_module_health(module_id, runtime, Some(deadline)).await,
8648                DrainScope::Endpoint(endpoint) => {
8649                    probe_endpoint_health(endpoint, runtime, Some(deadline)).await
8650                }
8651            };
8652            gauges_quiescent = match report {
8653                Ok(report) => match busy_gauge_observation(report.metrics.as_ref(), busy_gauges) {
8654                    BusyGaugeObservation::Quiescent => true,
8655                    BusyGaugeObservation::Busy => false,
8656                    BusyGaugeObservation::Omitted => {
8657                        if !omission_counted {
8658                            forwarding
8659                                .counters()
8660                                .increment_drains_with_undeclared_gauge();
8661                            omission_counted = true;
8662                        }
8663                        false
8664                    }
8665                },
8666                Err(err) => {
8667                    warn!(
8668                        module_id,
8669                        error = %err,
8670                        "drain health.check did not produce declared busy gauges; treating module as busy"
8671                    );
8672                    false
8673                }
8674            };
8675            next_probe_at = Instant::now() + runtime.health.cadence.max(REGISTRY_RELEASE_POLL);
8676        }
8677
8678        let in_flight = forwarding
8679            .endpoint_in_flight_count(endpoint)
8680            .map_err(SuperviseError::Forwarding)?;
8681        if in_flight == 0 && gauges_quiescent {
8682            return Ok(true);
8683        }
8684
8685        let now = Instant::now();
8686        if now >= deadline {
8687            return Ok(false);
8688        }
8689        let mut wait = deadline
8690            .saturating_duration_since(now)
8691            .min(REGISTRY_RELEASE_POLL);
8692        if !busy_gauges.is_empty() {
8693            wait = wait.min(next_probe_at.saturating_duration_since(now));
8694        }
8695        sleep(wait).await;
8696    }
8697}
8698
8699/// The `route.closed` `drained` value implied by a quiescence-wait outcome.
8700///
8701/// `Ok` is always honest and passed straight through -- the wait actually measured
8702/// in-flight state. `Err` means the wait produced no measurement at all (the
8703/// forwarding table's lock was poisoned), so `false` is reported as the one honest
8704/// constant: the drain did not complete. Never recomputed from route state, never a
8705/// third "unknown" value -- the caller must still send a well-formed `route.closed`.
8706fn drained_after_quiescence_wait(wait_result: &Result<bool, SuperviseError>) -> bool {
8707    match wait_result {
8708        Ok(drained) => *drained,
8709        Err(_) => false,
8710    }
8711}
8712
8713fn send_route_goodbyes(forwarding: &ForwardingTable, released_routes: Vec<GoodbyeTarget>) {
8714    for released in released_routes {
8715        let frame = match Frame::build_with_version(
8716            released.negotiated_ver,
8717            FrameType::Goodbye,
8718            control_flags(),
8719            released.channel,
8720            released.epoch,
8721            0,
8722            Vec::new(),
8723        ) {
8724            Ok(frame) => frame,
8725            Err(err) => {
8726                warn!(
8727                    route_channel = released.channel,
8728                    error = %err,
8729                    "failed to build supervisor drain route GOODBYE frame"
8730                );
8731                continue;
8732            }
8733        };
8734        if !released.close_on_delivery_failure() {
8735            crate::forwarding::send_module_route_goodbye(
8736                &forwarding.counters(),
8737                &released.sink,
8738                frame,
8739                released.module_id.as_deref(),
8740                "supervisor drain",
8741            );
8742            continue;
8743        }
8744        if let Err(err) = released.sink.try_send(frame) {
8745            warn!(
8746                target_connection_id = released.connection_id.get(),
8747                route_channel = released.channel,
8748                error = %err,
8749                "supervisor drain route GOODBYE was not delivered to client; closing target connection"
8750            );
8751            let _ = forwarding.escalate_client_delivery_failure(
8752                released.connection_id,
8753                released.channel,
8754                released.epoch,
8755                CloseReason::new(
8756                    "route_goodbye_delivery_failed",
8757                    format!(
8758                        "failed to enqueue supervisor drain route GOODBYE for channel {}: {err}",
8759                        released.channel
8760                    ),
8761                ),
8762                crate::forwarding::UndeliveredFrame {
8763                    module_id: released.module_id.as_deref(),
8764                    sink: &released.sink,
8765                },
8766            );
8767        }
8768    }
8769}
8770
8771fn send_module_draining(
8772    module_id: &str,
8773    reason: RouteCloseReason,
8774    deadline_ms: u64,
8775    target: &ModuleDrainTarget,
8776) {
8777    let body = match serde_json::to_vec(&ModuleControlCommand::Draining {
8778        reason,
8779        deadline_ms,
8780    }) {
8781        Ok(body) => body,
8782        Err(err) => {
8783            warn!(
8784                module_id,
8785                error = %err,
8786                "failed to encode module draining command"
8787            );
8788            return;
8789        }
8790    };
8791    let frame = match Frame::build_with_version(
8792        target.negotiated_ver,
8793        FrameType::Push,
8794        control_flags(),
8795        0,
8796        0,
8797        0,
8798        body,
8799    ) {
8800        Ok(frame) => frame,
8801        Err(err) => {
8802            warn!(
8803                module_id,
8804                error = %err,
8805                "failed to build module draining command frame"
8806            );
8807            return;
8808        }
8809    };
8810    if let Err(err) = target.sink.try_send(frame) {
8811        warn!(
8812            module_id,
8813            target_connection_id = target.endpoint.connection_id.get(),
8814            error = %err,
8815            "module draining command was not delivered to peer"
8816        );
8817    }
8818}
8819
8820/// The channel-0 GOODBYE that tells a module its stop is planned.
8821fn module_goodbye_frame(module_id: &str, negotiated_ver: u8) -> Option<Frame> {
8822    match Frame::build_with_version(
8823        negotiated_ver,
8824        FrameType::Goodbye,
8825        control_flags(),
8826        0,
8827        0,
8828        0,
8829        Vec::new(),
8830    ) {
8831        Ok(frame) => Some(frame),
8832        Err(err) => {
8833            warn!(
8834                module_id,
8835                error = %err,
8836                "failed to build module GOODBYE frame"
8837            );
8838            None
8839        }
8840    }
8841}
8842
8843/// Send every registered module connection its module GOODBYE at daemon
8844/// shutdown, then request that connection's close.
8845///
8846/// A module tells a planned stop from a lost daemon by whether a GOODBYE came
8847/// before EOF, so the GOODBYE must reach the socket before the close. A close
8848/// request does not wait for the connection's queued frames: its writer gets a
8849/// bounded grace after the close, is aborted if it overruns it, and the daemon
8850/// process may exit before that grace ends. So with `wait_for_flush`, each
8851/// connection is closed only after its writer has acknowledged writing the
8852/// GOODBYE, or once a short shared budget runs out, so one module that is not
8853/// reading cannot hold up the others or the shutdown. Without it the GOODBYEs
8854/// are only queued, for a shutdown the operator has told to stop waiting.
8855/// A connection that is already gone is skipped.
8856#[cfg(unix)]
8857async fn send_module_goodbyes_for_daemon_shutdown(
8858    forwarding: &Arc<ForwardingTable>,
8859    reason: &CloseReason,
8860    wait_for_flush: bool,
8861) {
8862    const GOODBYE_BUDGET: Duration = Duration::from_millis(500);
8863    let targets = match forwarding.module_connections() {
8864        Ok(targets) => targets,
8865        Err(err) => {
8866            warn!(error = %err, "could not list module connections for shutdown GOODBYE");
8867            return;
8868        }
8869    };
8870    let deadline = Instant::now() + GOODBYE_BUDGET;
8871    let mut sends = tokio::task::JoinSet::new();
8872    for target in targets {
8873        let Some(frame) = module_goodbye_frame(&target.module_id, target.negotiated_ver) else {
8874            continue;
8875        };
8876        if !wait_for_flush {
8877            if let Err(err) = target.sink.try_send(frame) {
8878                debug!(
8879                    module_id = %target.module_id,
8880                    error = %err,
8881                    "shutdown module GOODBYE was not queued"
8882                );
8883            }
8884            continue;
8885        }
8886        let forwarding = Arc::clone(forwarding);
8887        let reason = reason.clone();
8888        sends.spawn(async move {
8889            match timeout_at(deadline, target.sink.send_flushed(frame)).await {
8890                Ok(Ok(())) => {}
8891                Ok(Err(err)) => debug!(
8892                    module_id = %target.module_id,
8893                    error = %err,
8894                    "module connection closed before its shutdown GOODBYE was written"
8895                ),
8896                Err(_) => warn!(
8897                    module_id = %target.module_id,
8898                    budget = ?GOODBYE_BUDGET,
8899                    "shutdown module GOODBYE was not written within its budget; closing anyway"
8900                ),
8901            }
8902            forwarding.request_connection_close(target.endpoint.connection_id, reason);
8903        });
8904    }
8905    // Every task ends by the shared deadline, so this wait is bounded too.
8906    while sends.join_next().await.is_some() {}
8907}
8908
8909fn send_module_goodbye(module_id: &str, forwarding: &ForwardingTable, target: &ModuleDrainTarget) {
8910    let Some(frame) = module_goodbye_frame(module_id, target.negotiated_ver) else {
8911        return;
8912    };
8913    if let Err(err) = target.sink.try_send(frame) {
8914        warn!(
8915            module_id,
8916            target_connection_id = target.endpoint.connection_id.get(),
8917            error = %err,
8918            "supervisor drain module GOODBYE was not delivered to peer; closing module connection"
8919        );
8920        forwarding.request_connection_close(
8921            target.endpoint.connection_id,
8922            CloseReason::new(
8923                "module_goodbye_delivery_failed",
8924                format!("failed to enqueue supervisor drain module GOODBYE for module '{module_id}': {err}"),
8925            ),
8926        );
8927    }
8928}
8929
8930#[derive(Clone, Copy)]
8931struct ForwardingDrainContext<'a> {
8932    spec: &'a ModuleSpec,
8933    runtime: &'a SupervisorRuntimeConfig,
8934    registry: &'a Registry,
8935    scope: DrainScope,
8936}
8937
8938/// Which process a forwarding drain addresses.
8939#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8940enum DrainScope {
8941    /// Whatever endpoint is active for the module id: every plain stop,
8942    /// restart and reload. Also moves the module's state to `Draining`.
8943    Active,
8944    /// One specific endpoint: a swap's incumbent after cutover. Draining it by
8945    /// module id would resolve to the promoted candidate and leave neither
8946    /// process routable. The module's state is left alone, since the promoted
8947    /// candidate is what it describes and that process is running.
8948    Endpoint(crate::ModuleEndpointId),
8949}
8950
8951/// Whether a child being drained has already been asked to stop by the time
8952/// its drain wait starts.
8953///
8954/// The drain wait is the same budget whatever this says. What it decides is
8955/// whether the supervisor must ask by signal before that wait begins: a child
8956/// that nobody asked will sit out the whole budget and then be SIGKILLed,
8957/// healthy or not.
8958#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8959enum StopNotice {
8960    /// The module was sent `module.draining` and a module GOODBYE over its own
8961    /// registered connection, and stops itself.
8962    SentOverConnection,
8963    /// The forwarding drain found no registered connection for the module: a
8964    /// subc child spawned moments ago that has not sent HELLO yet, or a
8965    /// `protocol: "none"` child, which never registers.
8966    NoConnection,
8967    /// This path sends nothing over the module's connection: the supervisor has
8968    /// no forwarding table, or the caller stops the child without a forwarding
8969    /// drain.
8970    NotSent,
8971}
8972
8973async fn begin_forwarding_drain(
8974    spec: &ModuleSpec,
8975    runtime: &SupervisorRuntimeConfig,
8976    registry: &Registry,
8977    snapshot: &SharedSnapshot,
8978    enabled: Option<bool>,
8979    reason: RouteCloseReason,
8980) -> Result<StopNotice, SuperviseError> {
8981    let Some(forwarding) = runtime.forwarding.as_ref() else {
8982        return Err(SuperviseError::ReloadUnavailable {
8983            module_id: spec.module_id.clone(),
8984            reason: "supervisor was not configured with a forwarding table".to_string(),
8985        });
8986    };
8987
8988    begin_forwarding_drain_with(
8989        forwarding,
8990        ForwardingDrainContext {
8991            spec,
8992            runtime,
8993            registry,
8994            scope: DrainScope::Active,
8995        },
8996        snapshot,
8997        enabled,
8998        reason,
8999        runtime.drain_timeout,
9000    )
9001    .await
9002}
9003
9004async fn begin_forwarding_drain_if_configured(
9005    spec: &ModuleSpec,
9006    runtime: &SupervisorRuntimeConfig,
9007    registry: &Registry,
9008    snapshot: &SharedSnapshot,
9009    enabled: Option<bool>,
9010    reason: RouteCloseReason,
9011) -> Result<StopNotice, SuperviseError> {
9012    begin_forwarding_drain_with_timeout(
9013        spec,
9014        runtime,
9015        registry,
9016        snapshot,
9017        enabled,
9018        reason,
9019        runtime.drain_timeout,
9020    )
9021    .await
9022}
9023
9024/// Like [`begin_forwarding_drain_if_configured`] but with an explicit drain
9025/// budget, for paths where the operator overrides the module's configured one
9026/// (`supervisor.restart{drain_timeout_ms}`).
9027async fn begin_forwarding_drain_with_timeout(
9028    spec: &ModuleSpec,
9029    runtime: &SupervisorRuntimeConfig,
9030    registry: &Registry,
9031    snapshot: &SharedSnapshot,
9032    enabled: Option<bool>,
9033    reason: RouteCloseReason,
9034    drain_timeout: Duration,
9035) -> Result<StopNotice, SuperviseError> {
9036    let Some(forwarding) = runtime.forwarding.as_ref() else {
9037        return Ok(StopNotice::NotSent);
9038    };
9039
9040    begin_forwarding_drain_with(
9041        forwarding,
9042        ForwardingDrainContext {
9043            spec,
9044            runtime,
9045            registry,
9046            scope: DrainScope::Active,
9047        },
9048        snapshot,
9049        enabled,
9050        reason,
9051        drain_timeout,
9052    )
9053    .await
9054}
9055
9056async fn begin_forwarding_drain_with(
9057    forwarding: &ForwardingTable,
9058    context: ForwardingDrainContext<'_>,
9059    snapshot: &SharedSnapshot,
9060    enabled: Option<bool>,
9061    reason: RouteCloseReason,
9062    drain_timeout: Duration,
9063) -> Result<StopNotice, SuperviseError> {
9064    let ForwardingDrainContext {
9065        spec,
9066        runtime,
9067        registry,
9068        scope,
9069    } = context;
9070    debug_assert_ne!(reason, RouteCloseReason::Crash);
9071    let terminal = matches!(reason, RouteCloseReason::Disable);
9072    let drain_started_at = Instant::now();
9073    let drain_deadline = drain_started_at + drain_timeout;
9074    let deadline_ms =
9075        unix_ms_now().saturating_add(u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX));
9076    let busy_gauges = match scope {
9077        DrainScope::Active => declared_busy_gauges(registry, &spec.module_id)?,
9078        DrainScope::Endpoint(endpoint) => {
9079            declared_busy_gauges_for_connection(registry, endpoint.connection_id)?
9080        }
9081    };
9082
9083    // Admission gate first: route.open/commit and route REQUEST admission are closed
9084    // before the first quiescence check, so the outstanding count can only fall.
9085    let gate_started = Instant::now();
9086    let drain_target = match scope {
9087        DrainScope::Active => forwarding.begin_module_drain(&spec.module_id, reason),
9088        DrainScope::Endpoint(endpoint) => forwarding.begin_endpoint_drain(endpoint, reason),
9089    }
9090    .map_err(SuperviseError::Forwarding)?;
9091    // The instant admission closed, and how long taking the forwarding write
9092    // lock to close it took. The timeout line reports only the quiescence
9093    // wait, so without this a drain that started late looked like one that
9094    // started on time.
9095    info!(
9096        module_id = %spec.module_id,
9097        ?reason,
9098        gate_ms = u64::try_from(gate_started.elapsed().as_millis()).unwrap_or(u64::MAX),
9099        connected = drain_target.is_some(),
9100        "module drain began; route admission closed"
9101    );
9102    if scope == DrainScope::Active {
9103        update_snapshot(snapshot, Some(&spec.module_id), |state| {
9104            state.state = ModuleState::Draining;
9105            state.draining_to_replace =
9106                matches!(reason, RouteCloseReason::Restart | RouteCloseReason::Reload);
9107            if let Some(enabled) = enabled {
9108                state.enabled = enabled;
9109            }
9110        })?;
9111    }
9112
9113    let Some(target) = drain_target.as_ref() else {
9114        // Nothing was sent: the module has no registered connection to carry
9115        // `module.draining` or a GOODBYE. The caller must not assume the child
9116        // was asked to stop.
9117        return Ok(StopNotice::NoConnection);
9118    };
9119    {
9120        send_module_draining(&spec.module_id, reason, deadline_ms, target);
9121        let routes = forwarding
9122            .endpoint_routes(target.endpoint)
9123            .map_err(SuperviseError::Forwarding)?;
9124        let routes_notified = routes.len();
9125        crate::control::send_route_control_pushes(
9126            forwarding,
9127            routes.clone(),
9128            ClientControlPush::RouteClosing {
9129                module_id: spec.module_id.clone(),
9130                channels: Vec::new(),
9131                reason,
9132            },
9133        );
9134        send_route_goodbyes(forwarding, target.abandoned_bindings.clone());
9135
9136        // `route.closing` was just sent above: from here on every return path,
9137        // including an early one, MUST send `route.closed` before propagating
9138        // anything else. A client holds `closing` as a promise that a verdict is
9139        // coming; leaving early without `closed` strands it waiting forever, since
9140        // `closing` carries no timeout of its own.
9141        let wait_result = wait_for_forwarding_quiescence(
9142            forwarding,
9143            &spec.module_id,
9144            runtime,
9145            target.endpoint,
9146            drain_deadline,
9147            &busy_gauges,
9148            scope,
9149        )
9150        .await;
9151        let drained = drained_after_quiescence_wait(&wait_result);
9152        if let Err(err) = &wait_result {
9153            error!(
9154                module_id = %spec.module_id,
9155                ?reason,
9156                error = %err,
9157                "forwarding quiescence wait failed after route.closing; forcing route.closed(drained: false) so the client is not left waiting on an unfulfilled promise"
9158            );
9159        } else if !drained {
9160            // Name what the drain waited on. Without it the line says only that
9161            // something did not settle, and "one wedged call" and "every
9162            // session's held stream" read the same; the first is a module bug,
9163            // the second is a module that should end its streams on
9164            // module.draining. Read before teardown releases the routes.
9165            let holdouts = forwarding
9166                .endpoint_drain_holdouts(target.endpoint)
9167                .unwrap_or_default();
9168            warn!(
9169                module_id = %spec.module_id,
9170                waited = ?drain_timeout,
9171                ?reason,
9172                held_requests = holdouts.requests,
9173                held_routes = holdouts.routes,
9174                total_routes = holdouts.total_routes,
9175                top_connections = ?holdouts.top_connections,
9176                // `module_channel:corr`, so the module can find each held request
9177                // in its own log; capped, so `held_requests` is the full count.
9178                held = %holdouts
9179                    .held
9180                    .iter()
9181                    .map(|(channel, corr)| format!("{channel}:{corr}"))
9182                    .collect::<Vec<_>>()
9183                    .join(","),
9184                "route drain timed out before request quiescence; forcing teardown"
9185            );
9186        }
9187        crate::control::send_route_control_pushes(
9188            forwarding,
9189            routes,
9190            ClientControlPush::RouteClosed {
9191                module_id: spec.module_id.clone(),
9192                channels: Vec::new(),
9193                reason,
9194                drained,
9195                abandoned: target.abandoned_bindings.len() as u32,
9196                excluded_subscriptions: target.excluded_subscriptions,
9197                terminal: Some(terminal),
9198            },
9199        );
9200        wait_result?;
9201
9202        // `route.closed` has now been sent unconditionally above. From here the
9203        // remaining steps are cleanup (route + module GOODBYE) rather than a
9204        // promise the client is waiting on, but a lock-poisoned
9205        // `release_module_endpoint_routes` would otherwise skip the module
9206        // GOODBYE silently too -- send it before propagating the error.
9207        let released_routes = match forwarding.release_module_endpoint_routes(target.endpoint) {
9208            Ok(routes) => routes,
9209            Err(err) => {
9210                warn!(
9211                    module_id = %spec.module_id,
9212                    ?reason,
9213                    error = %err,
9214                    "failed to release module endpoint routes after route.closed; module GOODBYE will still be sent"
9215                );
9216                send_module_goodbye(&spec.module_id, forwarding, target);
9217                return Err(SuperviseError::Forwarding(err));
9218            }
9219        };
9220        let route_goodbye_count = released_routes.len();
9221        send_route_goodbyes(forwarding, released_routes);
9222        send_module_goodbye(&spec.module_id, forwarding, target);
9223
9224        // The drain's happy path was previously silent: every emission above is
9225        // best-effort with only its failure arm logged, so "were consumers told"
9226        // was unprovable from the daemon log (surfaced by a 30-minute consumer
9227        // hang where the open question was exactly whether teardown notice went
9228        // out). One summary line makes that class decidable in one grep.
9229        info!(
9230            module_id = %spec.module_id,
9231            ?reason,
9232            routes_notified,
9233            route_goodbyes = route_goodbye_count,
9234            abandoned_reservations = target.abandoned_bindings.len(),
9235            excluded_subscriptions = target.excluded_subscriptions,
9236            drained,
9237            "module drain complete; consumers notified via route.closing/route.closed pushes and per-route GOODBYE frames"
9238        );
9239    }
9240
9241    Ok(StopNotice::SentOverConnection)
9242}
9243
9244/// Wait for the freshly spawned child to take the ACTIVE slot for `module_id`,
9245/// the only slot a plain (non-swap) spawn can register into.
9246async fn wait_for_registration_after_reload(
9247    registry: &Registry,
9248    module_id: &str,
9249    snapshot: &SharedSnapshot,
9250    child: &mut SupervisedChild,
9251    wait: Duration,
9252) -> Result<RegistrationWaitOutcome, SuperviseError> {
9253    wait_for_slot_registration(
9254        registry,
9255        crate::registry::RegistrationSlot::Active(module_id),
9256        module_id,
9257        snapshot,
9258        child,
9259        wait,
9260    )
9261    .await
9262}
9263
9264/// Wait for `child` to register into `slot`, or to exit, or for `wait` to pass.
9265///
9266/// Keyed on the slot rather than the bare module id because during a swap the
9267/// id's active slot is already held by the incumbent: an id-keyed wait would
9268/// report the incumbent's registration as the candidate's and a candidate that
9269/// never registers would look registered. A swap candidate waits on
9270/// `crate::registry::RegistrationSlot::Candidate`.
9271async fn wait_for_slot_registration(
9272    registry: &Registry,
9273    slot: crate::registry::RegistrationSlot<'_>,
9274    module_id: &str,
9275    snapshot: &SharedSnapshot,
9276    child: &mut SupervisedChild,
9277    wait: Duration,
9278) -> Result<RegistrationWaitOutcome, SuperviseError> {
9279    let deadline = Instant::now() + wait;
9280    loop {
9281        if registry
9282            .registration(slot)
9283            .map_err(SuperviseError::Registry)?
9284            .is_some()
9285        {
9286            return Ok(RegistrationWaitOutcome::Registered);
9287        }
9288
9289        let now = Instant::now();
9290        if now >= deadline {
9291            return Ok(RegistrationWaitOutcome::TimedOut);
9292        }
9293        let remaining = deadline.saturating_duration_since(now);
9294        let poll = remaining.min(REGISTRY_RELEASE_POLL);
9295
9296        tokio::select! {
9297            wait_result = child.wait() => {
9298                let status = wait_result.map_err(|source| SuperviseError::Wait {
9299                    module_id: module_id.to_string(),
9300                    source,
9301                })?;
9302                return Ok(RegistrationWaitOutcome::Exited(classify_reaped_child_exit(
9303                    snapshot,
9304                    child,
9305                    &status,
9306                )));
9307            }
9308            _ = sleep(poll) => {}
9309        }
9310    }
9311}
9312
9313fn registration_failure_exit_report(mut exit_report: ExitReport) -> ExitReport {
9314    // A replacement process that exits before HELLO did not provide service, even
9315    // if it used status 0. Count it against the restart cap as a new-binary failure.
9316    if exit_report.kind != ExitKind::DeliberateSeverance {
9317        exit_report.kind = ExitKind::Crash;
9318    }
9319    exit_report
9320}
9321
9322async fn handle_reload_child_registration_failure(
9323    spec: &ModuleSpec,
9324    runtime: &SupervisorRuntimeConfig,
9325    registry: &Registry,
9326    process_liveness: &SupervisorProcessLiveness,
9327    snapshot: &SharedSnapshot,
9328    _child: &mut Option<SupervisedChild>,
9329    failure: ReloadRegistrationFailure,
9330) -> Result<(), SuperviseError> {
9331    let ReloadRegistrationFailure {
9332        exit_report,
9333        reason,
9334    } = failure;
9335    match on_child_exit(
9336        spec,
9337        runtime.restart_policy,
9338        registry,
9339        snapshot,
9340        &runtime.terminal_ring,
9341        &runtime.spawn_events,
9342        &runtime.child_roster,
9343        exit_report,
9344    )
9345    .await
9346    {
9347        NextAction::Stop {
9348            registration_released,
9349        } => {
9350            if registration_released {
9351                process_liveness.untrack_if_current(&spec.module_id, snapshot);
9352            }
9353        }
9354        NextAction::Restart { schedule } => {
9355            let delay = schedule.map_or(runtime.restart_policy.delay_for_restart(0), |schedule| {
9356                schedule.delay
9357            });
9358            if let Some(schedule) = schedule {
9359                log_crash_respawn(&spec.module_id, schedule);
9360            }
9361            schedule_respawn(
9362                runtime,
9363                snapshot,
9364                &spec.module_id,
9365                delay,
9366                RespawnKind::Spawn,
9367            )?;
9368        }
9369    }
9370    Err(SuperviseError::ReloadFailed {
9371        module_id: spec.module_id.clone(),
9372        reason,
9373    })
9374}
9375
9376async fn handle_reload_spawn_failure(
9377    spec: &ModuleSpec,
9378    runtime: &SupervisorRuntimeConfig,
9379    process_liveness: &SupervisorProcessLiveness,
9380    snapshot: &SharedSnapshot,
9381    _child: &mut Option<SupervisedChild>,
9382    reason: String,
9383) -> Result<(), SuperviseError> {
9384    let now = Instant::now();
9385    let mut schedule = None;
9386    update_snapshot(snapshot, Some(&spec.module_id), |state| {
9387        clear_current_process_facts(state);
9388        if state.enabled {
9389            schedule = state.next_crash_restart(&runtime.restart_policy, now);
9390            state.state = if schedule.is_some() {
9391                ModuleState::Restarting
9392            } else {
9393                ModuleState::Failed
9394            };
9395        } else {
9396            state.state = ModuleState::Disabled;
9397        }
9398    })?;
9399    if let Some(schedule) = schedule {
9400        schedule_respawn(
9401            runtime,
9402            snapshot,
9403            &spec.module_id,
9404            schedule.delay,
9405            RespawnKind::Spawn,
9406        )?;
9407    } else {
9408        process_liveness.untrack_if_current(&spec.module_id, snapshot);
9409    }
9410    Err(SuperviseError::ReloadFailed {
9411        module_id: spec.module_id.clone(),
9412        reason,
9413    })
9414}
9415
9416fn control_flags() -> Flags {
9417    Flags::new(false, Priority::Passive, false)
9418}
9419
9420#[allow(clippy::too_many_arguments)]
9421async fn drain_optional_child(
9422    module_id: &str,
9423    protocol: ModuleProtocol,
9424    stop_notice: StopNotice,
9425    registry: &Registry,
9426    forwarding: Option<&ForwardingTable>,
9427    snapshot: &SharedSnapshot,
9428    terminal_ring: &Arc<Mutex<TerminalRing>>,
9429    spawn_events: &SpawnEventFeed,
9430    child: &mut Option<SupervisedChild>,
9431    drain_timeout: Duration,
9432    final_state: ModuleState,
9433    enabled: Option<bool>,
9434) -> Result<(), SuperviseError> {
9435    if let Some(child) = child.take() {
9436        drain_child_to_state(
9437            module_id,
9438            protocol,
9439            stop_notice,
9440            registry,
9441            forwarding,
9442            snapshot,
9443            terminal_ring,
9444            spawn_events,
9445            child,
9446            drain_timeout,
9447            final_state,
9448            enabled,
9449        )
9450        .await
9451    } else {
9452        update_snapshot(snapshot, Some(module_id), |state| {
9453            state.state = final_state;
9454            if let Some(enabled) = enabled {
9455                state.enabled = enabled;
9456            }
9457            clear_current_process_facts(state);
9458        })?;
9459        release_dead_registration(registry, forwarding, snapshot, module_id).await
9460    }
9461}
9462
9463#[allow(clippy::too_many_arguments)]
9464async fn drain_child_to_state(
9465    module_id: &str,
9466    _protocol: ModuleProtocol,
9467    stop_notice: StopNotice,
9468    registry: &Registry,
9469    forwarding: Option<&ForwardingTable>,
9470    snapshot: &SharedSnapshot,
9471    terminal_ring: &Arc<Mutex<TerminalRing>>,
9472    spawn_events: &SpawnEventFeed,
9473    mut child: SupervisedChild,
9474    drain_timeout: Duration,
9475    final_state: ModuleState,
9476    enabled: Option<bool>,
9477) -> Result<(), SuperviseError> {
9478    let protocol = child.protocol;
9479    update_snapshot(snapshot, Some(module_id), |state| {
9480        state.state = ModuleState::Draining;
9481        state.draining_to_replace = final_state == ModuleState::Restarting;
9482        if let Some(enabled) = enabled {
9483            state.enabled = enabled;
9484        }
9485    })?;
9486
9487    // The wait below is the same budget in every case; what differs is
9488    // whether anything has ASKED the child to stop before it starts. Only a
9489    // forwarding drain that reached the module's registered connection has
9490    // (`module.draining`, then a module GOODBYE). Every other child was told
9491    // nothing: a `protocol: "none"` module, which never registers; a subc
9492    // module spawned moments ago that has not sent HELLO yet; or a stop that
9493    // runs no forwarding drain. Without a signal the budget is only a delay
9494    // in front of SIGKILL -- and the not-yet-registered child is the worst
9495    // case, because it registers into a module that is already draining,
9496    // is never told, and is killed while healthy.
9497    if stop_notice != StopNotice::SentOverConnection {
9498        if protocol == ModuleProtocol::Subc && stop_notice == StopNotice::NoConnection {
9499            info!(
9500                module_id,
9501                pid = child.pid,
9502                budget_ms = u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX),
9503                "module has no connection yet; requesting stop by signal"
9504            );
9505        }
9506        request_graceful_stop(module_id, &child);
9507    }
9508
9509    let exit_report = match timeout(drain_timeout, child.wait()).await {
9510        Ok(Ok(status)) => classify_reaped_child_exit(snapshot, &child, &status),
9511        Ok(Err(source)) => {
9512            fail_after_wait_error(
9513                module_id,
9514                snapshot,
9515                terminal_ring,
9516                spawn_events,
9517                format!("failed to wait during drain: {source}"),
9518            );
9519            return Err(SuperviseError::Wait {
9520                module_id: module_id.to_string(),
9521                source,
9522            });
9523        }
9524        Err(_) => {
9525            // Mirror the sibling arm above: state is already `Draining`, and an
9526            // error propagated from here would strand it there -- a state
9527            // `set_enabled(true)` cannot heal (`revive_terminal` matches only
9528            // `Failed | Stopped`), leaving an operator Restart as the only exit.
9529            // `Failed` before `?` keeps the module operator-visible and
9530            // revivable. Trigger is an ESRCH race (process exits between the
9531            // drain timeout firing and the kill) or a post-kill wait failure
9532            // (issue #34).
9533            //
9534            // Logged because the kill is otherwise visible only as signal 9 in
9535            // the terminal ring, and the budget it follows can be long enough
9536            // that consumers see a stretch of refusals with no stated cause.
9537            warn!(
9538                module_id,
9539                pid = child.pid,
9540                budget_ms = u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX),
9541                reason = ?final_state,
9542                ?stop_notice,
9543                "drain budget expired before the module exited; killing it"
9544            );
9545            child.start_kill().map_err(|source| {
9546                fail_after_wait_error(
9547                    module_id,
9548                    snapshot,
9549                    terminal_ring,
9550                    spawn_events,
9551                    format!("failed to kill during drain: {source}"),
9552                );
9553                SuperviseError::Kill {
9554                    module_id: module_id.to_string(),
9555                    source,
9556                }
9557            })?;
9558            let status = child.wait().await.map_err(|source| {
9559                fail_after_wait_error(
9560                    module_id,
9561                    snapshot,
9562                    terminal_ring,
9563                    spawn_events,
9564                    format!("failed to wait after drain kill: {source}"),
9565                );
9566                SuperviseError::Wait {
9567                    module_id: module_id.to_string(),
9568                    source,
9569                }
9570            })?;
9571            classify_reaped_child_exit(snapshot, &child, &status)
9572        }
9573    };
9574
9575    let detail = lock_snapshot(snapshot)?.drain_disposition_detail.take();
9576    publish_after_terminal_record(
9577        module_id,
9578        || {
9579            record_terminal_with_detail(
9580                module_id,
9581                terminal_ring,
9582                spawn_events,
9583                &exit_report,
9584                terminal_disposition(final_state),
9585                detail,
9586            )
9587        },
9588        || {
9589            update_snapshot(snapshot, Some(module_id), |state| {
9590                state.state = final_state;
9591                if let Some(enabled) = enabled {
9592                    state.enabled = enabled;
9593                }
9594                clear_current_process_facts(state);
9595                state.last_exit = Some(exit_report.clone());
9596                if exit_report.kind == ExitKind::DeliberateSeverance {
9597                    state.lifetime_restarts += 1;
9598                }
9599            })
9600        },
9601    )?;
9602    child.drain_stderr(module_id).await;
9603
9604    release_dead_registration(registry, forwarding, snapshot, module_id).await
9605}
9606
9607/// Ask a child that nothing else has asked to stop, by signal.
9608///
9609/// A registered subc module is asked over its own connection: the drain sends
9610/// `route.closing`/`route.closed` to its consumers, a GOODBYE per route, then a
9611/// module GOODBYE, and the module stops itself. A module that speaks no subc
9612/// wire receives none of that, and neither does a subc module that has not
9613/// registered yet, so for them the drain budget would be pure delay in front of
9614/// a SIGKILL -- and for a process with a store to flush (JetStream is the
9615/// reason `protocol: "none"` exists) a SIGKILL turns every ordinary teardown
9616/// into a recovery on the next start.
9617///
9618/// NEVER CALLED FOR A MODULE THAT WAS TOLD OVER ITS CONNECTION, and that is a
9619/// rule rather than an optimisation: that module's graceful stop is already
9620/// running by the time its child is drained, and a signal would race it.
9621///
9622/// Best-effort by construction. A child that has already exited is the ordinary
9623/// case rather than an error (the kill lands on a reaped or exiting pid), so a
9624/// failure is logged at debug and the wait-then-kill below still decides the
9625/// outcome.
9626#[cfg(unix)]
9627fn request_graceful_stop(module_id: &str, child: &SupervisedChild) {
9628    let Some(pid) = child
9629        .id()
9630        .and_then(|pid| i32::try_from(pid).ok())
9631        .and_then(rustix::process::Pid::from_raw)
9632    else {
9633        debug!(
9634            module_id,
9635            "no pid to signal for teardown; falling through to the drain wait"
9636        );
9637        return;
9638    };
9639    match rustix::process::kill_process(pid, rustix::process::Signal::TERM) {
9640        Ok(()) => debug!(
9641            module_id,
9642            "sent SIGTERM to a module nothing else asked to stop"
9643        ),
9644        Err(err) => debug!(
9645            module_id,
9646            error = %err,
9647            "SIGTERM to module failed; the drain wait and kill still apply"
9648        ),
9649    }
9650}
9651
9652/// Windows has no SIGTERM and no portable stand-in for one. The graceful stops
9653/// Windows does offer need cooperation this supervisor cannot assume: a console
9654/// control event requires sharing a console with the child, and `WM_CLOSE`
9655/// requires the child to pump a message loop. A supervised server process does
9656/// neither, so there is nothing to send and teardown is the wait followed by the
9657/// kill. Emulating a signal here would mean inventing a stop protocol, which is
9658/// the thing `protocol: "none"` exists to avoid.
9659#[cfg(not(unix))]
9660fn request_graceful_stop(module_id: &str, _child: &SupervisedChild) {
9661    debug!(
9662        module_id,
9663        "no graceful stop signal exists on this platform; teardown of a module nothing asked to stop waits, then kills"
9664    );
9665}
9666
9667fn terminal_disposition(final_state: ModuleState) -> TerminalDisposition {
9668    match final_state {
9669        ModuleState::Stopped => TerminalDisposition::Stopped,
9670        ModuleState::Disabled => TerminalDisposition::Disabled,
9671        ModuleState::Restarting => TerminalDisposition::Restarting,
9672        ModuleState::Failed => TerminalDisposition::Failed,
9673        ModuleState::Starting
9674        | ModuleState::Running
9675        | ModuleState::Unresponsive
9676        | ModuleState::Draining => {
9677            unreachable!("terminal exits only finish in terminal or restarting states")
9678        }
9679    }
9680}
9681
9682/// Release a reaped child's registration before allowing another spawn.
9683///
9684/// EOF is not a process-lifetime signal: an inherited socket can stay open
9685/// indefinitely, and serial frame dispatch can be waiting on egress instead of
9686/// reading EOF. After the normal release grace, request connection close (which
9687/// cancels both reads and dispatch), then allow one more release grace for the
9688/// connection guard's forwarding cleanup. Never evict a different connection.
9689async fn release_dead_registration(
9690    registry: &Registry,
9691    forwarding: Option<&ForwardingTable>,
9692    snapshot: &SharedSnapshot,
9693    module_id: &str,
9694) -> Result<(), SuperviseError> {
9695    let result = async {
9696        let registration = registry
9697            .get_module(module_id)
9698            .map_err(SuperviseError::Registry)?;
9699        match wait_for_registration_release(registry, module_id, REGISTRY_RELEASE_TIMEOUT).await {
9700            Ok(()) => return Ok(()),
9701            Err(SuperviseError::RegistrationStillActive { .. }) => {}
9702            Err(err) => return Err(err),
9703        }
9704        let pid = lock_snapshot(snapshot)?.reaped_pid;
9705        if let (Some(registration), Some(forwarding), Some(pid)) = (registration, forwarding, pid) {
9706            warn!(
9707                module_id,
9708                pid,
9709                connection_id = registration.connection_id.get(),
9710                "reaped module registration outlived release grace; closing dead connection"
9711            );
9712            forwarding.request_connection_close(
9713                registration.connection_id,
9714                CloseReason::new(
9715                    "supervised_process_reaped",
9716                    format!("module '{module_id}' pid {pid} exited"),
9717                ),
9718            );
9719            wait_for_slot_registration_release(
9720                registry,
9721                crate::registry::RegistrationSlot::Connection(registration.connection_id),
9722                REGISTRY_RELEASE_TIMEOUT,
9723            )
9724            .await?;
9725        }
9726        wait_for_registration_release(registry, module_id, Duration::ZERO).await
9727    }
9728    .await;
9729    if let Err(err) = &result {
9730        fail_snapshot(snapshot, Some(module_id), None);
9731        error!(module_id, error = %err, "registration release failed after child exit; module is failed and start can retry");
9732    }
9733    result
9734}
9735
9736/// Wait for the ACTIVE registration of `module_id` to go away, which is what a
9737/// plain stop or restart waits for before it spawns a replacement.
9738async fn wait_for_registration_release(
9739    registry: &Registry,
9740    module_id: &str,
9741    wait: Duration,
9742) -> Result<(), SuperviseError> {
9743    wait_for_slot_registration_release(
9744        registry,
9745        crate::registry::RegistrationSlot::Active(module_id),
9746        wait,
9747    )
9748    .await
9749}
9750
9751/// Wait for the registration in `slot` to go away.
9752///
9753/// Keyed on the slot rather than the bare module id because a successful swap
9754/// never empties the id's active slot (the promoted candidate is in it), so an
9755/// id-keyed wait for the incumbent's release would always time out. Draining a
9756/// swap's incumbent waits on `crate::registry::RegistrationSlot::Connection` with the
9757/// incumbent's connection instead.
9758async fn wait_for_slot_registration_release(
9759    registry: &Registry,
9760    slot: crate::registry::RegistrationSlot<'_>,
9761    wait: Duration,
9762) -> Result<(), SuperviseError> {
9763    let deadline = Instant::now() + wait;
9764    let mut release_events = registration_release_events().subscribe();
9765    let still_active = |registration: &crate::registry::ModuleRegistration| {
9766        SuperviseError::RegistrationStillActive {
9767            module_id: registration.manifest.module_id.clone(),
9768            waited: wait,
9769        }
9770    };
9771    loop {
9772        let _observed_generation = *release_events.borrow_and_update();
9773        let Some(registration) = registry
9774            .registration(slot)
9775            .map_err(SuperviseError::Registry)?
9776        else {
9777            return Ok(());
9778        };
9779
9780        let now = Instant::now();
9781        if now >= deadline {
9782            return Err(still_active(&registration));
9783        }
9784
9785        let remaining = deadline.saturating_duration_since(now);
9786        match timeout(remaining, release_events.changed()).await {
9787            Ok(Ok(())) | Ok(Err(_)) => {}
9788            Err(_) => return Err(still_active(&registration)),
9789        }
9790    }
9791}
9792
9793#[cfg(test)]
9794mod slot_registration_wait_tests {
9795    use super::*;
9796    use crate::registry::{ConnectionId, RegistrationSlot};
9797    use subc_protocol::manifest::ModuleManifest;
9798
9799    #[tokio::test]
9800    async fn enable_release_failure_is_failed_and_a_second_enable_retries() {
9801        let registry = Arc::new(Registry::default());
9802        let supervisor = Supervisor::new_for_test(Arc::clone(&registry), RestartPolicy::default());
9803        let runtime = supervisor.runtime_config();
9804        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::disabled()));
9805        let spec = ModuleSpec {
9806            module_id: "enable-stale-registration".to_string(),
9807            program: PathBuf::from("/missing/enable-retry-test"),
9808            args: Vec::new(),
9809            env: Vec::new(),
9810            reserved: false,
9811            reserved_prefixes: Vec::new(),
9812            protocol: ModuleProtocol::Subc,
9813            overlap: Default::default(),
9814        };
9815        let connection = ConnectionId::new(90);
9816        registry
9817            .register_with_control_ops(
9818                ModuleManifest::builder(&spec.module_id, "0.1.0").build(),
9819                1,
9820                connection,
9821                Vec::new(),
9822            )
9823            .unwrap();
9824        let mut child = None;
9825        let err = set_child_enabled(
9826            &spec,
9827            &runtime,
9828            &registry,
9829            &supervisor.process_liveness,
9830            &snapshot,
9831            &mut child,
9832            true,
9833        )
9834        .await
9835        .unwrap_err();
9836        assert!(matches!(
9837            err,
9838            SuperviseError::RegistrationStillActive { .. }
9839        ));
9840        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
9841        assert!(child.is_none());
9842        registry.deregister_connection(connection).unwrap();
9843        let err = set_child_enabled(
9844            &spec,
9845            &runtime,
9846            &registry,
9847            &supervisor.process_liveness,
9848            &snapshot,
9849            &mut child,
9850            true,
9851        )
9852        .await
9853        .unwrap_err();
9854        assert!(
9855            matches!(err, SuperviseError::Spawn { .. }),
9856            "second enable must attempt a spawn: {err}"
9857        );
9858        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
9859    }
9860
9861    const INCUMBENT: u64 = 1;
9862    const CANDIDATE: u64 = 2;
9863
9864    fn swapped_registry() -> Arc<Registry> {
9865        let registry = Arc::new(Registry::default());
9866        let manifest = ModuleManifest::builder("m", "0.1.0").build();
9867        registry
9868            .register_with_control_ops(
9869                manifest.clone(),
9870                1,
9871                ConnectionId::new(INCUMBENT),
9872                Vec::new(),
9873            )
9874            .unwrap();
9875        registry
9876            .register_candidate_with_control_ops(
9877                manifest,
9878                1,
9879                ConnectionId::new(CANDIDATE),
9880                Vec::new(),
9881            )
9882            .unwrap();
9883        registry
9884    }
9885
9886    /// After a promotion the id's active slot is held by the new process, so an
9887    /// id-keyed wait for the incumbent's release can never succeed; the
9888    /// connection-keyed wait completes as soon as the incumbent deregisters.
9889    #[tokio::test]
9890    async fn incumbent_release_is_awaited_by_connection_not_by_module_id() {
9891        let registry = swapped_registry();
9892        registry.promote_candidate("m").unwrap().unwrap();
9893
9894        assert!(matches!(
9895            wait_for_registration_release(&registry, "m", Duration::from_millis(50)).await,
9896            Err(SuperviseError::RegistrationStillActive { .. })
9897        ));
9898
9899        // Still held while the incumbent's connection has not deregistered.
9900        assert!(matches!(
9901            wait_for_slot_registration_release(
9902                &registry,
9903                RegistrationSlot::Connection(ConnectionId::new(INCUMBENT)),
9904                Duration::from_millis(50),
9905            )
9906            .await,
9907            Err(SuperviseError::RegistrationStillActive { .. })
9908        ));
9909
9910        let releaser = Arc::clone(&registry);
9911        let release = tokio::spawn(async move {
9912            sleep(Duration::from_millis(20)).await;
9913            releaser
9914                .deregister_connection(ConnectionId::new(INCUMBENT))
9915                .unwrap();
9916            notify_registration_release();
9917        });
9918        wait_for_slot_registration_release(
9919            &registry,
9920            RegistrationSlot::Connection(ConnectionId::new(INCUMBENT)),
9921            Duration::from_secs(5),
9922        )
9923        .await
9924        .expect("the incumbent's own registration is released");
9925        release.await.unwrap();
9926        assert!(registry.get_module("m").unwrap().is_some());
9927    }
9928
9929    /// The candidate slot is waited on separately from the active slot: the
9930    /// incumbent's registration neither holds up nor stands in for it.
9931    #[tokio::test]
9932    async fn candidate_slot_wait_ignores_the_incumbents_registration() {
9933        let registry = swapped_registry();
9934        assert!(matches!(
9935            wait_for_slot_registration_release(
9936                &registry,
9937                RegistrationSlot::Candidate("m"),
9938                Duration::from_millis(50),
9939            )
9940            .await,
9941            Err(SuperviseError::RegistrationStillActive { .. })
9942        ));
9943        registry
9944            .deregister_connection(ConnectionId::new(CANDIDATE))
9945            .unwrap();
9946        wait_for_slot_registration_release(
9947            &registry,
9948            RegistrationSlot::Candidate("m"),
9949            Duration::from_millis(50),
9950        )
9951        .await
9952        .expect("a candidate slot with no candidate is released");
9953        assert!(registry
9954            .registration(RegistrationSlot::Active("m"))
9955            .unwrap()
9956            .is_some());
9957    }
9958}
9959
9960fn classify_exit(status: &ExitStatus) -> ExitReport {
9961    ExitReport {
9962        kind: if status.success() {
9963            ExitKind::Clean
9964        } else {
9965            ExitKind::Crash
9966        },
9967        code: status.code(),
9968        signal: exit_signal(status),
9969        at_ms: unix_ms_now(),
9970    }
9971}
9972
9973/// The terminal record for a module whose `wait()` call itself errored (e.g. the
9974/// child was already reaped out-of-band). There is no `ExitStatus` to read a code
9975/// or signal from -- `None`/`None` is the honest shape, not a guess -- but the
9976/// disposition still must be `Failed` so the terminal ring is not silently missing
9977/// an entry, matching what `fail_snapshot` records for this same arm.
9978fn wait_error_exit_report() -> ExitReport {
9979    ExitReport {
9980        kind: ExitKind::Crash,
9981        code: None,
9982        signal: None,
9983        at_ms: unix_ms_now(),
9984    }
9985}
9986
9987#[cfg(unix)]
9988fn exit_signal(status: &ExitStatus) -> Option<i32> {
9989    use std::os::unix::process::ExitStatusExt;
9990
9991    status.signal()
9992}
9993
9994#[cfg(not(unix))]
9995fn exit_signal(_status: &ExitStatus) -> Option<i32> {
9996    None
9997}
9998
9999/// Give an operator-touched module its full crash budget back.
10000///
10001/// Named for the counter it used to zero; it now empties the in-window ring,
10002/// which is the same act. `lifetime_restarts` is untouched on purpose -- the
10003/// ledger of what happened survives every operator action.
10004fn reset_restart_count(snapshot: &SharedSnapshot, module_id: &str) -> Result<(), SuperviseError> {
10005    update_snapshot(snapshot, Some(module_id), |state| {
10006        state.clear_crash_restarts();
10007    })
10008}
10009
10010fn set_running(
10011    snapshot: &SharedSnapshot,
10012    child: &SupervisedChild,
10013    module_id: &str,
10014    spawn_events: &SpawnEventFeed,
10015) -> Result<(), SuperviseError> {
10016    let mut state = snapshot.lock().map_err(|_| SuperviseError::StatePoisoned {
10017        module_id: Some(module_id.to_string()),
10018    })?;
10019    state.spawn_generation = spawn_events.emit_spawned(module_id, child.pid, child.spawned_at_ms);
10020    if std::mem::take(&mut state.coalesced_restart_pending) {
10021        let generation = state.spawn_generation;
10022        info!(module_id, "restart already satisfied by generation {generation}; coalesced pending request completed");
10023    }
10024    state.drain_disposition_detail = None;
10025    state.spawn_failure = None;
10026    state.waiting_for_previous_pid = None;
10027    // Every caller of this is a plain spawn, which always uses the primary key;
10028    // a promoted swap candidate sets the flag itself after this returns.
10029    state.in_alternate_slot = false;
10030    state.configuration_updated_since_spawn = false;
10031    state.spawned_protocol = Some(child.protocol);
10032    state.state = ModuleState::Running;
10033    state.enabled = true;
10034    state.process_alive = true;
10035    state.pid = child.id();
10036    #[cfg(windows)]
10037    {
10038        state.pipe_consumed = child
10039            .nonce_delivery
10040            .as_ref()
10041            .map(|delivery| delivery.consumed());
10042    }
10043    #[cfg(target_os = "macos")]
10044    {
10045        state.report_ready = Some(Arc::clone(&child.report_ready));
10046    }
10047    state.spawned_at_ms = Some(child.spawned_at_ms);
10048    state.spawned_from = Some(child.spawned_from.clone());
10049    state.spawned_file_identity = child.spawned_file_identity.clone();
10050    state.process_start_time = child.process_start_time;
10051    Ok(())
10052}
10053
10054fn clear_current_process_facts(state: &mut SupervisorSnapshot) {
10055    state.waiting_for_previous_pid = None;
10056    state.process_alive = false;
10057    state.spawned_protocol = None;
10058    state.pid = None;
10059    #[cfg(windows)]
10060    {
10061        state.pipe_consumed = None;
10062    }
10063    #[cfg(target_os = "macos")]
10064    {
10065        state.report_ready = None;
10066    }
10067    state.spawned_at_ms = None;
10068    state.spawned_from = None;
10069    state.spawned_file_identity = None;
10070    state.process_start_time = None;
10071    state.deliberate_severance = None;
10072}
10073
10074#[cfg(test)]
10075fn record_deliberate_severance(
10076    snapshot: &SharedSnapshot,
10077    identity: ProcessIdentity,
10078) -> Result<(), SuperviseError> {
10079    update_snapshot(snapshot, None, |state| {
10080        state.deliberate_severance = Some(identity);
10081    })
10082}
10083
10084fn apply_deliberate_severance_marker(
10085    snapshot: &SharedSnapshot,
10086    exited_identity: Option<ProcessIdentity>,
10087    mut exit_report: ExitReport,
10088) -> ExitReport {
10089    let marker = lock_snapshot(snapshot)
10090        .ok()
10091        .and_then(|mut state| state.deliberate_severance.take());
10092    if marker.is_some() && marker == exited_identity {
10093        exit_report.kind = ExitKind::DeliberateSeverance;
10094    }
10095    exit_report
10096}
10097
10098fn classify_reaped_child_exit(
10099    snapshot: &SharedSnapshot,
10100    child: &SupervisedChild,
10101    status: &ExitStatus,
10102) -> ExitReport {
10103    let _ = update_snapshot(snapshot, None, |state| {
10104        state.reaped_pid = Some(child.pid);
10105        state.spawn_failure = child.spawn_failure.clone();
10106    });
10107    apply_deliberate_severance_marker(snapshot, child.process_identity(), classify_exit(status))
10108}
10109
10110fn fail_snapshot(
10111    snapshot: &SharedSnapshot,
10112    module_id: Option<&str>,
10113    last_exit: Option<ExitReport>,
10114) {
10115    if let Err(err) = update_snapshot(snapshot, module_id, |state| {
10116        state.state = ModuleState::Failed;
10117        clear_current_process_facts(state);
10118        if let Some(last_exit) = last_exit {
10119            state.last_exit = Some(last_exit);
10120        }
10121    }) {
10122        error!(error = %err, "failed to mark supervisor state failed");
10123    }
10124}
10125
10126fn update_snapshot(
10127    snapshot: &SharedSnapshot,
10128    module_id: Option<&str>,
10129    update: impl FnOnce(&mut SupervisorSnapshot),
10130) -> Result<(), SuperviseError> {
10131    let mut state = snapshot.lock().map_err(|_| SuperviseError::StatePoisoned {
10132        module_id: module_id.map(ToOwned::to_owned),
10133    })?;
10134    update(&mut state);
10135    Ok(())
10136}
10137
10138const SLOW_SNAPSHOT_LOCK_THRESHOLD: Duration = Duration::from_millis(250);
10139
10140fn lock_snapshot_for_control<'a>(
10141    snapshot: &'a SharedSnapshot,
10142    module_id: &str,
10143    caller: &'static str,
10144) -> Result<std::sync::MutexGuard<'a, SupervisorSnapshot>, SuperviseError> {
10145    let started_at = Instant::now();
10146    let guard = lock_snapshot(snapshot)?;
10147    let waited = started_at.elapsed();
10148    if waited >= SLOW_SNAPSHOT_LOCK_THRESHOLD {
10149        warn!(
10150            module_id = %module_id,
10151            waited_ms = waited.as_millis() as u64,
10152            caller = %caller,
10153            "slow snapshot lock"
10154        );
10155    }
10156    Ok(guard)
10157}
10158
10159fn lock_snapshot(
10160    snapshot: &SharedSnapshot,
10161) -> Result<std::sync::MutexGuard<'_, SupervisorSnapshot>, SuperviseError> {
10162    snapshot
10163        .lock()
10164        .map_err(|_| SuperviseError::StatePoisoned { module_id: None })
10165}
10166
10167#[cfg(test)]
10168mod terminal_history_tests {
10169    use std::{
10170        path::PathBuf,
10171        sync::Arc,
10172        time::{Duration, Instant},
10173    };
10174
10175    use tokio::{sync::mpsc, time::sleep};
10176
10177    use super::{
10178        apply_deliberate_severance_marker, daemon_will_restart, drain_child_to_state,
10179        drained_after_quiescence_wait, handle_reload_spawn_failure, health_restart_child,
10180        lock_snapshot, on_child_exit, record_deliberate_severance, record_wait_error_terminal,
10181        reset_restart_count, spawn_and_mark_running, update_snapshot, wait_error_exit_report,
10182        ExitKind, ExitReport, ModuleProtocol, ModuleSpec, ModuleState, NextAction, ProcessIdentity,
10183        RestartPolicy, SpawnEventKind, StopNotice, SuperviseError, SupervisedConfiguration,
10184        SupervisedModule, SupervisedModuleInner, Supervisor, SupervisorHandle,
10185        SupervisorHealthStatus, SupervisorSnapshot,
10186    };
10187    // The supervisor's clock, distinct from the `std::time::Instant` these tests
10188    // use for their own wall-clock deadlines: crash-restart instants must be on
10189    // the same clock the production code stamps them with, which is tokio's (and
10190    // is what `start_paused` tests can move).
10191    use super::Instant as ClockInstant;
10192    use crate::{
10193        registry::Registry,
10194        terminal_ring::{TerminalRing, TerminalRingConfig},
10195    };
10196    use std::sync::Mutex;
10197    use subc_control::TerminalDisposition;
10198
10199    /// See the twin in `control.rs` for why this derives the path from
10200    /// `current_exe()` and why the existence check is here: `--lib` alone does
10201    /// not build `[[bin]]` targets, and a bare spawn then fails with a raw
10202    /// `NotFound` that reads as a broken test rather than an unbuilt dependency.
10203    pub(super) fn fake_aft_stub_path() -> PathBuf {
10204        let mut path = std::env::current_exe().expect("current_exe available in tests");
10205        path.pop();
10206        path.pop();
10207        path.push(if cfg!(windows) {
10208            "fake-aft-stub.exe"
10209        } else {
10210            "fake-aft-stub"
10211        });
10212        assert!(
10213            path.exists(),
10214            "fake-aft-stub not built at {}: run `cargo test -p subc-core` (which builds \
10215             [[bin]] targets) rather than `cargo test -p subc-core --lib` (which does not)",
10216            path.display()
10217        );
10218        path
10219    }
10220
10221    #[test]
10222    fn reserved_never_spawned_refuses_every_hello() {
10223        // The canary hole: a reserved id whose module has never spawned had NO
10224        // gate entry and admitted anyone -- the reservation protected the nonce
10225        // holder, not the NAME. Now the entry is present with no legitimate
10226        // holder and refuses all comers.
10227        let supervisor = SupervisorHandle::default();
10228        supervisor.apply_identity_configuration(&ModuleSpec {
10229            module_id: "never-spawned".to_string(),
10230            program: PathBuf::from("/usr/bin/false"),
10231            args: Vec::new(),
10232            env: Vec::new(),
10233            reserved: true,
10234            reserved_prefixes: Vec::new(),
10235            protocol: ModuleProtocol::Subc,
10236            overlap: Default::default(),
10237        });
10238        assert!(
10239            supervisor
10240                .reserved_hello_rejection("never-spawned", Some("any-forged-nonce"))
10241                .is_some(),
10242            "forged nonce must refuse on a reserved never-spawned id"
10243        );
10244        assert!(
10245            supervisor
10246                .reserved_hello_rejection("never-spawned", None)
10247                .is_some(),
10248            "absent nonce must refuse on a reserved never-spawned id"
10249        );
10250        // And a real spawn nonce minted later admits exactly that nonce.
10251        supervisor.set_spawn_nonce("never-spawned", "minted".to_string());
10252        supervisor.apply_identity_configuration(&ModuleSpec {
10253            module_id: "never-spawned".to_string(),
10254            program: PathBuf::from("/usr/bin/false"),
10255            args: Vec::new(),
10256            env: Vec::new(),
10257            reserved: true,
10258            reserved_prefixes: Vec::new(),
10259            protocol: ModuleProtocol::Subc,
10260            overlap: Default::default(),
10261        });
10262        assert!(supervisor
10263            .reserved_hello_rejection("never-spawned", Some("minted"))
10264            .is_none());
10265        assert!(supervisor
10266            .reserved_hello_rejection("never-spawned", Some("forged"))
10267            .is_some());
10268    }
10269
10270    /// Put `count` crash restarts on a snapshot's ring as if they had all just
10271    /// happened, which is what "spent budget" looks like to every reader.
10272    fn seed_crash_restarts(state: &mut SupervisorSnapshot, count: u32) {
10273        let now = ClockInstant::now();
10274        for _ in 0..count {
10275            state.crash_restarts.push_back(now);
10276        }
10277    }
10278
10279    /// Age the oldest recorded restart out of `window`, standing in for the hours
10280    /// that would otherwise have to pass. Injecting the instant is the point: a
10281    /// test that slept a real window would take ten minutes and still prove less.
10282    fn age_oldest_crash_restart_out_of_window(state: &mut SupervisorSnapshot, window: Duration) {
10283        let aged = state
10284            .crash_restarts
10285            .front()
10286            .expect("a crash restart must be recorded before it can be aged")
10287            .checked_sub(window + Duration::from_secs(1))
10288            .expect("the test clock is far enough from its origin to age an instant");
10289        state.crash_restarts[0] = aged;
10290    }
10291
10292    fn snapshot_with_restarts(enabled: bool, count: u32) -> SupervisorSnapshot {
10293        let mut state = SupervisorSnapshot::new(ModuleState::Running, enabled);
10294        seed_crash_restarts(&mut state, count);
10295        state
10296    }
10297
10298    #[test]
10299    fn daemon_owned_recovery_predicate_uses_the_pre_increment_budget() {
10300        let policy = RestartPolicy::new(3, Duration::ZERO);
10301        let now = ClockInstant::now();
10302        assert!(daemon_will_restart(
10303            &mut snapshot_with_restarts(true, 2),
10304            &policy,
10305            now
10306        ));
10307        assert!(!daemon_will_restart(
10308            &mut snapshot_with_restarts(true, 3),
10309            &policy,
10310            now
10311        ));
10312        assert!(!daemon_will_restart(
10313            &mut snapshot_with_restarts(false, 0),
10314            &policy,
10315            now
10316        ));
10317    }
10318
10319    #[test]
10320    fn crash_restart_backoff_escalates_with_in_window_count() {
10321        let policy = RestartPolicy::new(4, Duration::from_millis(100))
10322            .with_max_backoff(Duration::from_secs(30));
10323        let now = ClockInstant::now();
10324        let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
10325        let schedules = (0..4)
10326            .map(|_| {
10327                state
10328                    .next_crash_restart(&policy, now)
10329                    .expect("the test policy allows four crash restarts")
10330            })
10331            .collect::<Vec<_>>();
10332
10333        assert_eq!(
10334            schedules
10335                .iter()
10336                .map(|schedule| schedule.restart_in_window)
10337                .collect::<Vec<_>>(),
10338            vec![0, 1, 2, 3]
10339        );
10340        assert_eq!(
10341            schedules
10342                .iter()
10343                .map(|schedule| schedule.delay)
10344                .collect::<Vec<_>>(),
10345            vec![
10346                Duration::from_millis(100),
10347                Duration::from_secs(1),
10348                Duration::from_secs(10),
10349                Duration::from_secs(30),
10350            ]
10351        );
10352    }
10353
10354    #[test]
10355    fn crash_restart_backoff_resets_after_ring_clear() {
10356        let policy = RestartPolicy::new(3, Duration::from_millis(100));
10357        let now = ClockInstant::now();
10358        let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
10359        assert_eq!(
10360            state.next_crash_restart(&policy, now).unwrap().delay,
10361            Duration::from_millis(100)
10362        );
10363        assert_eq!(
10364            state.next_crash_restart(&policy, now).unwrap().delay,
10365            Duration::from_secs(1)
10366        );
10367
10368        state.clear_crash_restarts();
10369        let schedule = state
10370            .next_crash_restart(&policy, now)
10371            .expect("a cleared ring must allow another restart");
10372        assert_eq!(schedule.restart_in_window, 0);
10373        assert_eq!(schedule.delay, Duration::from_millis(100));
10374    }
10375
10376    #[test]
10377    fn crash_restart_backoff_ignores_aged_restarts() {
10378        let policy = RestartPolicy::new(3, Duration::from_millis(100));
10379        let now = ClockInstant::now();
10380        let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
10381        state
10382            .next_crash_restart(&policy, now)
10383            .expect("the first restart is allowed");
10384        state
10385            .next_crash_restart(&policy, now)
10386            .expect("the second restart is allowed");
10387        state.crash_restarts[0] = now
10388            .checked_sub(policy.window + Duration::from_secs(1))
10389            .expect("the fake clock can age a restart past the window");
10390
10391        let schedule = state
10392            .next_crash_restart(&policy, now)
10393            .expect("an aged restart must release its slot");
10394        assert_eq!(schedule.restart_in_window, 1);
10395        assert_eq!(schedule.delay, Duration::from_secs(1));
10396        assert_eq!(state.crash_restarts.len(), 2);
10397    }
10398
10399    /// The budget is a rate: the same three spent restarts refuse a respawn
10400    /// while they are recent and allow one once they have aged past the window.
10401    /// Nothing about the module changed in between, which is the whole point.
10402    #[test]
10403    fn a_budget_spent_before_the_window_no_longer_refuses() {
10404        let policy = RestartPolicy::new(3, Duration::ZERO);
10405        let mut state = snapshot_with_restarts(true, 3);
10406        let now = ClockInstant::now();
10407        assert!(!daemon_will_restart(&mut state, &policy, now));
10408
10409        assert!(daemon_will_restart(
10410            &mut state,
10411            &policy,
10412            now + policy.window + Duration::from_secs(1)
10413        ));
10414        assert!(
10415            state.crash_restarts.is_empty(),
10416            "reading the budget must drop the instants that left the window"
10417        );
10418    }
10419
10420    fn module_with_recovery_snapshot(
10421        state: ModuleState,
10422        enabled: bool,
10423        restart_count: u32,
10424    ) -> SupervisedModule {
10425        let registry = Arc::new(Registry::default());
10426        let supervisor =
10427            Supervisor::new_for_test(Arc::clone(&registry), RestartPolicy::new(3, Duration::ZERO));
10428        let runtime = supervisor.runtime_config();
10429        let spec = ModuleSpec {
10430            module_id: "recovery-snapshot".to_string(),
10431            program: fake_aft_stub_path(),
10432            args: Vec::new(),
10433            env: Vec::new(),
10434            reserved: false,
10435            reserved_prefixes: Vec::new(),
10436            protocol: ModuleProtocol::Subc,
10437            overlap: Default::default(),
10438        };
10439        let mut snapshot = SupervisorSnapshot::new(state, enabled);
10440        seed_crash_restarts(&mut snapshot, restart_count);
10441        // These tests read synthetic snapshots. A real child and monitor would
10442        // race those reads by replacing the requested state during startup.
10443        let (commands, _rx) = mpsc::channel(4);
10444        SupervisedModule {
10445            inner: Arc::new(SupervisedModuleInner {
10446                module_id: spec.module_id.clone(),
10447                registry,
10448                snapshot: Arc::new(Mutex::new(snapshot)),
10449                configuration: Arc::new(Mutex::new(SupervisedConfiguration {
10450                    spec,
10451                    health: runtime.health,
10452                })),
10453                stderr_ring: runtime.stderr_ring,
10454                terminal_ring: runtime.terminal_ring,
10455                commands,
10456                monitor: Mutex::new(None),
10457                restart_policy: runtime.restart_policy,
10458                effective_drain_timeout: runtime.effective_drain_timeout,
10459                provenance_probe: supervisor.provenance_probe.clone(),
10460            }),
10461        }
10462    }
10463
10464    #[cfg(target_os = "linux")]
10465    #[tokio::test]
10466    async fn no_cgroup_placement_does_not_block_fake_aft_stub_spawn() {
10467        let supervisor =
10468            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
10469                .with_cgroup_placement(None);
10470        let result = supervisor.spawn(ModuleSpec {
10471            module_id: "no-cgroup-placement".to_string(),
10472            program: fake_aft_stub_path(),
10473            args: Vec::new(),
10474            env: Vec::new(),
10475            reserved: false,
10476            reserved_prefixes: Vec::new(),
10477            protocol: ModuleProtocol::Subc,
10478            overlap: Default::default(),
10479        });
10480
10481        assert!(
10482            result.is_ok(),
10483            "no delegation must not turn an otherwise valid spawn into a failure: {result:?}"
10484        );
10485    }
10486
10487    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10488    async fn undecided_snapshot_uses_shared_restart_predicate() {
10489        assert!(module_with_recovery_snapshot(ModuleState::Running, true, 2)
10490            .will_recover_after_connection_loss()
10491            .unwrap());
10492        assert!(
10493            !module_with_recovery_snapshot(ModuleState::Running, true, 3)
10494                .will_recover_after_connection_loss()
10495                .unwrap()
10496        );
10497    }
10498
10499    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10500    async fn restarting_snapshot_at_exhausted_budget_is_non_terminal() {
10501        assert!(
10502            module_with_recovery_snapshot(ModuleState::Restarting, true, 3)
10503                .will_recover_after_connection_loss()
10504                .unwrap()
10505        );
10506    }
10507
10508    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10509    async fn terminal_phase_snapshots_are_terminal_before_budget_exhaustion() {
10510        assert!(!module_with_recovery_snapshot(ModuleState::Failed, true, 0)
10511            .will_recover_after_connection_loss()
10512            .unwrap());
10513        assert!(
10514            !module_with_recovery_snapshot(ModuleState::Disabled, true, 0)
10515                .will_recover_after_connection_loss()
10516                .unwrap()
10517        );
10518    }
10519
10520    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10521    async fn warming_snapshot_is_limited_to_startup_phases() {
10522        for state in [
10523            ModuleState::Starting,
10524            ModuleState::Running,
10525            ModuleState::Restarting,
10526        ] {
10527            assert!(
10528                module_with_recovery_snapshot(state, true, 0)
10529                    .is_warming()
10530                    .unwrap(),
10531                "{state:?} should be warming"
10532            );
10533        }
10534        for state in [
10535            ModuleState::Unresponsive,
10536            ModuleState::Draining,
10537            ModuleState::Stopped,
10538            ModuleState::Failed,
10539            ModuleState::Disabled,
10540        ] {
10541            assert!(
10542                !module_with_recovery_snapshot(state, true, 0)
10543                    .is_warming()
10544                    .unwrap(),
10545                "{state:?} should not be warming"
10546            );
10547        }
10548    }
10549
10550    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10551    async fn terminal_history_survives_respawn_and_keeps_both_crashes_in_order() {
10552        let registry = Arc::new(Registry::default());
10553        let supervisor =
10554            Supervisor::new_for_test(Arc::clone(&registry), RestartPolicy::new(1, Duration::ZERO));
10555        let module = supervisor
10556            .spawn(ModuleSpec {
10557                module_id: "terminal-history".to_string(),
10558                program: fake_aft_stub_path(),
10559                args: Vec::new(),
10560                env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
10561                reserved: false,
10562                reserved_prefixes: Vec::new(),
10563                protocol: ModuleProtocol::Subc,
10564                overlap: Default::default(),
10565            })
10566            .unwrap();
10567
10568        let deadline = Instant::now() + Duration::from_secs(5);
10569        loop {
10570            let history = module.terminal_history();
10571            if history.entries.len() == 2 {
10572                assert_eq!(module.status().unwrap().state, ModuleState::Failed);
10573                assert_eq!(history.dropped, 0);
10574                assert_eq!(
10575                    history
10576                        .entries
10577                        .iter()
10578                        .map(|entry| entry.exit_code)
10579                        .collect::<Vec<_>>(),
10580                    vec![Some(23), Some(23)]
10581                );
10582                assert!(history.entries[0].at_ms <= history.entries[1].at_ms);
10583                return;
10584            }
10585            assert!(
10586                Instant::now() < deadline,
10587                "module did not retain two terminal exits: {history:?}"
10588            );
10589            sleep(Duration::from_millis(10)).await;
10590        }
10591    }
10592
10593    /// A disable issued while a crash respawn is still backing off must preempt
10594    /// that respawn: the operator's stop wins, the disable must not queue behind
10595    /// the backoff, and the module must never come back up afterwards.
10596    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10597    async fn disable_during_crash_backoff_cancels_pending_respawn() {
10598        let backoff = Duration::from_secs(2);
10599        let supervisor = Supervisor::new_for_test(
10600            Arc::new(Registry::default()),
10601            RestartPolicy::new(10, backoff),
10602        );
10603        let module = supervisor
10604            .spawn(ModuleSpec {
10605                module_id: "disable-during-backoff".to_string(),
10606                program: fake_aft_stub_path(),
10607                args: Vec::new(),
10608                env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
10609                reserved: false,
10610                reserved_prefixes: Vec::new(),
10611                protocol: ModuleProtocol::Subc,
10612                overlap: Default::default(),
10613            })
10614            .unwrap();
10615
10616        // Wait for the first crash to put the module into its backoff window.
10617        let deadline = Instant::now() + Duration::from_secs(5);
10618        loop {
10619            if module.status().unwrap().state == ModuleState::Restarting {
10620                break;
10621            }
10622            assert!(
10623                Instant::now() < deadline,
10624                "module never entered the crash backoff"
10625            );
10626            sleep(Duration::from_millis(10)).await;
10627        }
10628
10629        let started = Instant::now();
10630        module.set_enabled(false).await.unwrap();
10631        let waited = started.elapsed();
10632
10633        assert!(
10634            waited < backoff / 2,
10635            "disable waited {waited:?} behind the {backoff:?} crash backoff; the operator command must preempt the pending respawn"
10636        );
10637        assert_eq!(module.status().unwrap().state, ModuleState::Disabled);
10638
10639        // Outlast the backoff: the respawn it was counting down to must never run.
10640        sleep(backoff + Duration::from_millis(500)).await;
10641        let status = module.status().unwrap();
10642        assert_eq!(status.state, ModuleState::Disabled);
10643        assert_eq!(
10644            status.spawn_generation, 1,
10645            "module respawned after the operator disabled it"
10646        );
10647    }
10648
10649    /// A `protocol: "none"` child that stays parked and exits 0 on SIGTERM:
10650    /// the shape of nats-server, the program this rule exists for.
10651    #[cfg(unix)]
10652    fn protocol_none_sigterm_exits_clean_spec(
10653        module_id: &str,
10654        dir: &std::path::Path,
10655    ) -> (ModuleSpec, PathBuf, PathBuf) {
10656        let ready = dir.join("ready");
10657        let marker = dir.join("sigterm");
10658        let spec = ModuleSpec {
10659            module_id: module_id.to_string(),
10660            program: fake_aft_stub_path(),
10661            args: Vec::new(),
10662            env: vec![
10663                ("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string()),
10664                (
10665                    "FAKE_AFT_SIGTERM_MARKER_PATH".to_string(),
10666                    marker.display().to_string(),
10667                ),
10668                (
10669                    "FAKE_AFT_NEVER_CONNECT_READY_PATH".to_string(),
10670                    ready.display().to_string(),
10671                ),
10672            ],
10673            reserved: false,
10674            reserved_prefixes: Vec::new(),
10675            protocol: ModuleProtocol::None,
10676            overlap: Default::default(),
10677        };
10678        (spec, ready, marker)
10679    }
10680
10681    /// Wait for a file the child writes, so a signal is never sent before the
10682    /// child's SIGTERM handler is installed (the default disposition would
10683    /// kill it by signal and the exit would not be clean).
10684    #[cfg(unix)]
10685    async fn wait_for_file(path: &std::path::Path) {
10686        let deadline = Instant::now() + Duration::from_secs(10);
10687        while !path.exists() {
10688            assert!(
10689                Instant::now() < deadline,
10690                "{} never appeared",
10691                path.display()
10692            );
10693            sleep(Duration::from_millis(10)).await;
10694        }
10695    }
10696
10697    /// A protocol-none module that exits 0 because something OUTSIDE the
10698    /// supervisor sent it SIGTERM is respawned, and the terminal record carries
10699    /// the crash-path disposition rather than `stopped`.
10700    #[cfg(unix)]
10701    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10702    async fn protocol_none_unrequested_clean_exit_restarts_as_a_crash() {
10703        let dir = cortexkit_test_support::ScratchDir::new("none-unrequested-clean-exit");
10704        let (spec, ready, marker) =
10705            protocol_none_sigterm_exits_clean_spec("none-unrequested-clean-exit", dir.path());
10706        let supervisor = Supervisor::new_for_test(
10707            Arc::new(Registry::default()),
10708            RestartPolicy::new(3, Duration::ZERO),
10709        );
10710        let module = supervisor.spawn(spec).unwrap();
10711        wait_for_file(&ready).await;
10712        // The ready file proves the child installed its SIGTERM handler, not
10713        // that the supervisor has processed the privacy trampoline's exec
10714        // acknowledgement. On macOS status withholds the pid until then.
10715        let deadline = Instant::now() + Duration::from_secs(10);
10716        let first_pid = loop {
10717            let status = module.status().unwrap();
10718            if status.state == ModuleState::Running {
10719                if let Some(pid) = status.pid {
10720                    break pid;
10721                }
10722            }
10723            assert!(
10724                Instant::now() < deadline,
10725                "a running module must report its pid after exec confirmation: {status:?}"
10726            );
10727            sleep(Duration::from_millis(10)).await;
10728        };
10729
10730        rustix::process::kill_process(
10731            rustix::process::Pid::from_raw(i32::try_from(first_pid).unwrap()).unwrap(),
10732            rustix::process::Signal::TERM,
10733        )
10734        .unwrap();
10735
10736        let deadline = Instant::now() + Duration::from_secs(10);
10737        let respawned = loop {
10738            let status = module.status().unwrap();
10739            if status.state == ModuleState::Running
10740                && status.pid.is_some_and(|pid| pid != first_pid)
10741            {
10742                break status;
10743            }
10744            assert!(
10745                Instant::now() < deadline,
10746                "protocol-none module was not respawned after an unrequested clean exit: {status:?}"
10747            );
10748            sleep(Duration::from_millis(10)).await;
10749        };
10750        assert_eq!(respawned.spawn_generation, 2);
10751        assert!(
10752            marker.exists(),
10753            "the child must have exited through its SIGTERM handler (exit 0), or this proves nothing about clean exits"
10754        );
10755
10756        let history = module.terminal_history();
10757        assert_eq!(history.entries.len(), 1, "{history:?}");
10758        let entry = &history.entries[0];
10759        assert_eq!(entry.exit_code, Some(0));
10760        assert_eq!(entry.exit_kind, subc_control::TerminalExitKind::Clean);
10761        assert_eq!(entry.disposition, TerminalDisposition::Restarting);
10762
10763        module.stop().await.unwrap();
10764    }
10765
10766    /// Repeated unrequested clean exits of a protocol-none module spend the
10767    /// restart budget exactly as crashes do, and the module ends `failed` with
10768    /// the budget named.
10769    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10770    async fn protocol_none_repeated_clean_exits_exhaust_the_restart_budget() {
10771        let supervisor = Supervisor::new_for_test(
10772            Arc::new(Registry::default()),
10773            RestartPolicy::new(1, Duration::ZERO),
10774        );
10775        let module = supervisor
10776            .spawn(ModuleSpec {
10777                module_id: "none-clean-exit-budget".to_string(),
10778                program: fake_aft_stub_path(),
10779                args: Vec::new(),
10780                env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "0".to_string())],
10781                reserved: false,
10782                reserved_prefixes: Vec::new(),
10783                protocol: ModuleProtocol::None,
10784                overlap: Default::default(),
10785            })
10786            .unwrap();
10787
10788        // Failed follows the terminal write; this deadline only bounds a hang,
10789        // not an assumed duration for the two launches or their exit recording.
10790        let deadline = Instant::now() + Duration::from_secs(10);
10791        loop {
10792            let status = module.status().unwrap();
10793            if status.state == ModuleState::Failed {
10794                break;
10795            }
10796            assert!(
10797                Instant::now() < deadline,
10798                "module never exhausted its budget: {status:?} {:?}",
10799                module.terminal_history()
10800            );
10801            sleep(Duration::from_millis(10)).await;
10802        }
10803        let history = module.terminal_history();
10804        assert_eq!(
10805            history
10806                .entries
10807                .iter()
10808                .map(|entry| (entry.exit_code, entry.disposition.clone()))
10809                .collect::<Vec<_>>(),
10810            vec![
10811                (Some(0), TerminalDisposition::Restarting),
10812                (Some(0), TerminalDisposition::Failed),
10813            ]
10814        );
10815        let detail = history.entries[1]
10816            .disposition_detail
10817            .as_deref()
10818            .expect("a budget failure names the budget");
10819        assert!(detail.contains("max_restarts=1"), "{detail}");
10820        assert_eq!(module.status().unwrap().spawn_generation, 2);
10821    }
10822
10823    #[test]
10824    fn restart_budget_failure_is_published_after_its_terminal_record() {
10825        use std::io::{Read, Write};
10826        let supervisor = Supervisor::new_for_test(
10827            Arc::new(Registry::default()),
10828            RestartPolicy::new(0, Duration::ZERO),
10829        );
10830        let runtime = supervisor.runtime_config();
10831        let spec = ModuleSpec {
10832            protocol: ModuleProtocol::None,
10833            ..windowed_crash_spec("budget-publication-order")
10834        };
10835        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(
10836            ModuleState::Running,
10837            true,
10838        )));
10839        lock_snapshot(&snapshot).unwrap().process_alive = true;
10840        let reaped_snapshot = snapshot.clone();
10841        let mut barrier = terminal_record_barrier(&runtime.spawn_events);
10842        let history = runtime.terminal_ring.clone();
10843        let reap = std::thread::spawn(move || {
10844            tokio::runtime::Builder::new_current_thread()
10845                .enable_all()
10846                .build()
10847                .unwrap()
10848                .block_on(on_child_exit(
10849                    &spec,
10850                    runtime.restart_policy,
10851                    &supervisor.registry,
10852                    &reaped_snapshot,
10853                    &runtime.terminal_ring,
10854                    &runtime.spawn_events,
10855                    &runtime.child_roster,
10856                    ExitReport {
10857                        kind: ExitKind::Clean,
10858                        code: Some(0),
10859                        signal: None,
10860                        at_ms: 1,
10861                    },
10862                ))
10863        });
10864        let mut reached = [0];
10865        barrier.read_exact(&mut reached).unwrap();
10866        assert_eq!(&reached, b"R");
10867        let (before_state, before_alive) = {
10868            let state = lock_snapshot(&snapshot).unwrap();
10869            (state.state, state.process_alive)
10870        };
10871        let before_history = history.lock().unwrap().snapshot();
10872        barrier.write_all(b"X").unwrap();
10873        assert!(matches!(reap.join().unwrap(), NextAction::Stop { .. }));
10874        assert!(before_history.entries.is_empty());
10875        assert_ne!(
10876            before_state,
10877            ModuleState::Failed,
10878            "Failed was visible before its terminal record could be written"
10879        );
10880        assert!(before_alive);
10881        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
10882        let history = history.lock().unwrap().snapshot();
10883        assert_eq!(history.entries.len(), 1);
10884        assert_eq!(history.entries[0].exit_code, Some(0));
10885        assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
10886    }
10887
10888    fn terminal_record_barrier(events: &super::SpawnEventFeed) -> std::net::TcpStream {
10889        use std::net::{TcpListener, TcpStream};
10890        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
10891        let writer = TcpStream::connect(listener.local_addr().unwrap()).unwrap();
10892        let (barrier, _) = listener.accept().unwrap();
10893        for stream in [&writer, &barrier] {
10894            stream
10895                .set_read_timeout(Some(Duration::from_secs(10)))
10896                .unwrap();
10897            stream
10898                .set_write_timeout(Some(Duration::from_secs(10)))
10899                .unwrap();
10900        }
10901        *events.1.lock().unwrap() = Some(writer);
10902        barrier
10903    }
10904
10905    pub(super) fn before_terminal_record(events: &super::SpawnEventFeed) {
10906        use std::io::{Read, Write};
10907        let barrier = events.1.lock().unwrap().take();
10908        if let Some(mut barrier) = barrier {
10909            barrier.write_all(b"R").unwrap();
10910            let mut release = [0];
10911            barrier.read_exact(&mut release).unwrap();
10912            assert_eq!(&release, b"X");
10913        }
10914    }
10915
10916    #[test]
10917    fn health_restart_budget_failure_is_published_after_its_terminal_record() {
10918        use std::io::{Read, Write};
10919
10920        let supervisor = Supervisor::new_for_test(
10921            Arc::new(Registry::default()),
10922            RestartPolicy::new(0, Duration::ZERO),
10923        );
10924        let mut runtime = supervisor.runtime_config();
10925        let module = module_with_recovery_snapshot(ModuleState::Running, true, 0);
10926        let snapshot = module.inner.snapshot.clone();
10927        runtime.terminal_ring = module.inner.terminal_ring.clone();
10928        let spec = ModuleSpec {
10929            module_id: module.module_id().into(),
10930            program: fake_aft_stub_path(),
10931            protocol: ModuleProtocol::None,
10932            env: vec![("FAKE_AFT_EXIT_CODE".into(), "17".into())],
10933            ..windowed_crash_spec(module.module_id())
10934        };
10935        let mut barrier = terminal_record_barrier(&runtime.spawn_events);
10936        let reap = std::thread::spawn(move || {
10937            tokio::runtime::Builder::new_current_thread()
10938                .enable_all()
10939                .build()
10940                .unwrap()
10941                .block_on(async {
10942                    let mut child =
10943                        Some(spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap());
10944                    health_restart_child(
10945                        &spec,
10946                        &runtime,
10947                        &supervisor.registry,
10948                        &supervisor.process_liveness,
10949                        &snapshot,
10950                        &mut child,
10951                        SupervisorHealthStatus::Failing,
10952                        Some("health probe did not answer"),
10953                        1,
10954                    )
10955                    .await
10956                    .unwrap();
10957                    assert!(child.is_none());
10958                });
10959        });
10960        // The loopback barrier acknowledges entry into terminal recording by
10961        // health_restart_child's drain, then waits for the test's release byte.
10962        // Status and history are observed while that writer is held, not after
10963        // an elapsed-time guess about when the health action has completed.
10964        let mut reached = [0];
10965        barrier.read_exact(&mut reached).unwrap();
10966        assert_eq!(&reached, b"R");
10967        let before_status = module.status().unwrap();
10968        let before_history = module.terminal_history();
10969        barrier.write_all(b"X").unwrap();
10970        reap.join().unwrap();
10971        assert!(before_history.entries.is_empty());
10972        assert_ne!(
10973            before_status.state,
10974            ModuleState::Failed,
10975            "Failed was visible before its terminal record could be written"
10976        );
10977        assert!(
10978            before_status.process_alive,
10979            "process death was visible before its terminal record could be written"
10980        );
10981        let failed = module.status().unwrap();
10982        assert_eq!(failed.state, ModuleState::Failed);
10983        assert!(!failed.process_alive);
10984        let history = module.terminal_history();
10985        assert_eq!(history.entries.len(), 1);
10986        assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
10987        assert_eq!(
10988            history.entries[0].disposition_detail.as_deref(),
10989            Some("crash budget exhausted: max_restarts=0 within window_secs=600")
10990        );
10991    }
10992
10993    #[test]
10994    fn observed_exits_publish_process_death_after_terminal_history() {
10995        use std::io::{Read, Write};
10996        for (kind, enabled, shutdown, wait_error, expected_state) in [
10997            (ExitKind::Clean, true, false, false, ModuleState::Stopped),
10998            (ExitKind::Crash, true, false, false, ModuleState::Restarting),
10999            (ExitKind::Crash, false, false, false, ModuleState::Disabled),
11000            (
11001                ExitKind::DeliberateSeverance,
11002                true,
11003                false,
11004                false,
11005                ModuleState::Restarting,
11006            ),
11007            #[cfg(unix)]
11008            (ExitKind::Crash, true, true, false, ModuleState::Stopped),
11009            (ExitKind::Crash, true, false, true, ModuleState::Failed),
11010        ] {
11011            let supervisor = Supervisor::new_for_test(
11012                Arc::new(Registry::default()),
11013                RestartPolicy::new(1, Duration::ZERO),
11014            );
11015            let runtime = supervisor.runtime_config();
11016            #[cfg(unix)]
11017            if shutdown {
11018                runtime.child_roster.close();
11019            }
11020            #[cfg(not(unix))]
11021            let _ = shutdown;
11022            let spec = windowed_crash_spec("exit-publication-order");
11023            let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(
11024                ModuleState::Running,
11025                enabled,
11026            )));
11027            lock_snapshot(&snapshot).unwrap().process_alive = true;
11028            let reaped_snapshot = snapshot.clone();
11029            let history = runtime.terminal_ring.clone();
11030            let mut barrier = terminal_record_barrier(&runtime.spawn_events);
11031            let reap = std::thread::spawn(move || {
11032                if wait_error {
11033                    super::fail_after_wait_error(
11034                        &spec.module_id,
11035                        &reaped_snapshot,
11036                        &runtime.terminal_ring,
11037                        &runtime.spawn_events,
11038                        "wait failed".into(),
11039                    );
11040                } else {
11041                    tokio::runtime::Builder::new_current_thread()
11042                        .enable_all()
11043                        .build()
11044                        .unwrap()
11045                        .block_on(on_child_exit(
11046                            &spec,
11047                            runtime.restart_policy,
11048                            &supervisor.registry,
11049                            &reaped_snapshot,
11050                            &runtime.terminal_ring,
11051                            &runtime.spawn_events,
11052                            &runtime.child_roster,
11053                            ExitReport {
11054                                kind,
11055                                code: Some(0),
11056                                signal: None,
11057                                at_ms: 1,
11058                            },
11059                        ));
11060                }
11061            });
11062            let mut reached = [0];
11063            barrier.read_exact(&mut reached).unwrap();
11064            assert_eq!(&reached, b"R");
11065            let before_alive = lock_snapshot(&snapshot).unwrap().process_alive;
11066            let before_history = history.lock().unwrap().snapshot();
11067            barrier.write_all(b"X").unwrap();
11068            reap.join().unwrap();
11069            assert!(
11070                before_alive,
11071                "{kind:?}: death was published before recording"
11072            );
11073            assert!(before_history.entries.is_empty());
11074            assert_eq!(lock_snapshot(&snapshot).unwrap().state, expected_state);
11075            assert!(!lock_snapshot(&snapshot).unwrap().process_alive);
11076            assert_eq!(history.lock().unwrap().snapshot().entries.len(), 1);
11077        }
11078    }
11079
11080    #[tokio::test]
11081    async fn health_restart_budget_failure_remains_failed_when_journal_append_fails() {
11082        let dir = cortexkit_test_support::ScratchDir::new("health-budget-journal-failure");
11083        let path = dir.join("terminals.jsonl");
11084        std::fs::create_dir(&path).unwrap();
11085        let supervisor = Supervisor::new_for_test(
11086            Arc::new(Registry::default()),
11087            RestartPolicy::new(0, Duration::ZERO),
11088        )
11089        .with_terminal_journal(path, "health-budget-journal-failure".into());
11090        let runtime = supervisor.runtime_config();
11091        let spec = ModuleSpec {
11092            program: fake_aft_stub_path(),
11093            protocol: ModuleProtocol::None,
11094            env: vec![("FAKE_AFT_EXIT_CODE".into(), "17".into())],
11095            ..windowed_crash_spec("health-budget-journal-failure")
11096        };
11097        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11098        let mut child = Some(spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap());
11099        health_restart_child(
11100            &spec,
11101            &runtime,
11102            &supervisor.registry,
11103            &supervisor.process_liveness,
11104            &snapshot,
11105            &mut child,
11106            SupervisorHealthStatus::Failing,
11107            None,
11108            1,
11109        )
11110        .await
11111        .unwrap();
11112        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
11113        assert!(!lock_snapshot(&snapshot).unwrap().process_alive);
11114        let history = runtime
11115            .terminal_ring
11116            .lock()
11117            .unwrap()
11118            .durable_history(&spec.module_id);
11119        assert!(history.journal_write_failures > 0);
11120        assert_eq!(history.entries.len(), 1);
11121        assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
11122    }
11123
11124    #[test]
11125    fn health_restart_budget_failure_remains_failed_when_exit_recording_panics() {
11126        let supervisor = Supervisor::new_for_test(
11127            Arc::new(Registry::default()),
11128            RestartPolicy::new(0, Duration::ZERO),
11129        );
11130        let runtime = supervisor.runtime_config();
11131        let spec = ModuleSpec {
11132            program: fake_aft_stub_path(),
11133            protocol: ModuleProtocol::None,
11134            env: vec![("FAKE_AFT_EXIT_CODE".into(), "17".into())],
11135            ..windowed_crash_spec("health-budget-recording-panic")
11136        };
11137        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11138        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
11139            tokio::runtime::Builder::new_current_thread()
11140                .enable_all()
11141                .build()
11142                .unwrap()
11143                .block_on(async {
11144                    let mut child =
11145                        Some(spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap());
11146                    runtime.spawn_events.0.lock().unwrap().seq = u64::MAX;
11147                    health_restart_child(
11148                        &spec,
11149                        &runtime,
11150                        &supervisor.registry,
11151                        &supervisor.process_liveness,
11152                        &snapshot,
11153                        &mut child,
11154                        SupervisorHealthStatus::Failing,
11155                        None,
11156                        1,
11157                    )
11158                    .await
11159                    .unwrap();
11160                });
11161        }));
11162        let panic = result.expect_err("recording must still unwind");
11163        assert_eq!(
11164            panic.downcast_ref::<String>().map(String::as_str),
11165            Some("spawn event sequence exhausted")
11166        );
11167        let state = lock_snapshot(&snapshot).unwrap();
11168        assert_eq!(state.state, ModuleState::Failed);
11169        assert!(!state.process_alive);
11170        assert!(state.last_exit.is_some());
11171    }
11172
11173    #[tokio::test]
11174    async fn restart_budget_failure_remains_failed_when_journal_append_fails() {
11175        let dir = cortexkit_test_support::ScratchDir::new("budget-journal-failure");
11176        let path = dir.join("terminals.jsonl");
11177        std::fs::create_dir(&path).unwrap();
11178        let supervisor = Supervisor::new_for_test(
11179            Arc::new(Registry::default()),
11180            RestartPolicy::new(0, Duration::ZERO),
11181        )
11182        .with_terminal_journal(path, "budget-journal-failure".into());
11183        let runtime = supervisor.runtime_config();
11184        let spec = windowed_crash_spec("budget-journal-failure");
11185        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11186        assert!(matches!(
11187            on_child_exit(
11188                &spec,
11189                runtime.restart_policy,
11190                &supervisor.registry,
11191                &snapshot,
11192                &runtime.terminal_ring,
11193                &runtime.spawn_events,
11194                &runtime.child_roster,
11195                crash_exit_report(1),
11196            )
11197            .await,
11198            NextAction::Stop { .. }
11199        ));
11200        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
11201        let history = runtime
11202            .terminal_ring
11203            .lock()
11204            .unwrap()
11205            .durable_history(&spec.module_id);
11206        assert!(history.journal_write_failures > 0);
11207        assert_eq!(history.entries.len(), 1);
11208        assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
11209    }
11210
11211    #[test]
11212    fn restart_budget_failure_remains_failed_when_exit_recording_panics() {
11213        let supervisor = Supervisor::new_for_test(
11214            Arc::new(Registry::default()),
11215            RestartPolicy::new(0, Duration::ZERO),
11216        );
11217        let runtime = supervisor.runtime_config();
11218        let spec = windowed_crash_spec("budget-recording-panic");
11219        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11220        runtime.spawn_events.emit_spawned(&spec.module_id, 1, 1);
11221        // Exhausting the event sequence makes emit_exited panic before the
11222        // terminal write, exercising publication on the recording unwind.
11223        runtime.spawn_events.0.lock().unwrap().seq = u64::MAX;
11224        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
11225            tokio::runtime::Builder::new_current_thread()
11226                .enable_all()
11227                .build()
11228                .unwrap()
11229                .block_on(on_child_exit(
11230                    &spec,
11231                    runtime.restart_policy,
11232                    &supervisor.registry,
11233                    &snapshot,
11234                    &runtime.terminal_ring,
11235                    &runtime.spawn_events,
11236                    &runtime.child_roster,
11237                    crash_exit_report(1),
11238                ))
11239        }));
11240        let panic = result.err().expect("recording must still unwind");
11241        assert_eq!(
11242            panic.downcast_ref::<String>().map(String::as_str),
11243            Some("spawn event sequence exhausted")
11244        );
11245        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
11246    }
11247
11248    /// A stop the supervisor itself requests still stops a protocol-none
11249    /// module, even though the child answers the SIGTERM with exit 0.
11250    #[cfg(unix)]
11251    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11252    async fn protocol_none_requested_stop_and_disable_do_not_respawn() {
11253        for disable in [false, true] {
11254            let label = if disable {
11255                "none-requested-disable"
11256            } else {
11257                "none-requested-stop"
11258            };
11259            let dir = cortexkit_test_support::ScratchDir::new(label);
11260            let (spec, ready, marker) = protocol_none_sigterm_exits_clean_spec(label, dir.path());
11261            let supervisor = Supervisor::new_for_test(
11262                Arc::new(Registry::default()),
11263                RestartPolicy::new(3, Duration::ZERO),
11264            );
11265            let module = supervisor.spawn(spec).unwrap();
11266            wait_for_file(&ready).await;
11267
11268            if disable {
11269                module.set_enabled(false).await.unwrap();
11270            } else {
11271                module.stop().await.unwrap();
11272            }
11273            assert!(
11274                marker.exists(),
11275                "{label}: the child must have left through its SIGTERM handler with exit 0"
11276            );
11277
11278            // Long enough for a zero-backoff respawn to have happened if the
11279            // exit had been treated as a crash.
11280            sleep(Duration::from_millis(500)).await;
11281            let status = module.status().unwrap();
11282            let expected = if disable {
11283                ModuleState::Disabled
11284            } else {
11285                ModuleState::Stopped
11286            };
11287            assert_eq!(status.state, expected, "{label}");
11288            assert_eq!(
11289                status.spawn_generation, 1,
11290                "{label}: respawned after a requested stop"
11291            );
11292            let history = module.terminal_history();
11293            assert_eq!(history.entries.len(), 1, "{label}: {history:?}");
11294            assert_eq!(history.entries[0].exit_code, Some(0), "{label}");
11295            assert_ne!(
11296                history.entries[0].disposition,
11297                TerminalDisposition::Restarting,
11298                "{label}"
11299            );
11300        }
11301    }
11302
11303    /// A subc-wire module that exits 0 on its own is still a stop: the
11304    /// protocol-none rule must not reach it.
11305    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11306    async fn subc_wire_clean_exit_is_still_a_stop() {
11307        let supervisor = Supervisor::new_for_test(
11308            Arc::new(Registry::default()),
11309            RestartPolicy::new(3, Duration::ZERO),
11310        );
11311        let module = supervisor
11312            .spawn(ModuleSpec {
11313                module_id: "wire-clean-exit".to_string(),
11314                program: fake_aft_stub_path(),
11315                args: Vec::new(),
11316                env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "0".to_string())],
11317                reserved: false,
11318                reserved_prefixes: Vec::new(),
11319                protocol: ModuleProtocol::Subc,
11320                overlap: Default::default(),
11321            })
11322            .unwrap();
11323
11324        let deadline = Instant::now() + Duration::from_secs(10);
11325        while module.terminal_history().entries.is_empty() {
11326            assert!(Instant::now() < deadline, "module never exited");
11327            sleep(Duration::from_millis(10)).await;
11328        }
11329        // Long enough for a zero-backoff respawn to have happened.
11330        sleep(Duration::from_millis(500)).await;
11331        let status = module.status().unwrap();
11332        assert_eq!(status.state, ModuleState::Stopped);
11333        assert_eq!(status.spawn_generation, 1);
11334        let history = module.terminal_history();
11335        assert_eq!(history.entries.len(), 1, "{history:?}");
11336        assert_eq!(history.entries[0].exit_code, Some(0));
11337        assert_eq!(history.entries[0].disposition, TerminalDisposition::Stopped);
11338    }
11339
11340    #[cfg(unix)]
11341    #[tokio::test]
11342    async fn reload_exit_keeps_roster_until_terminal_is_recorded_during_shutdown() {
11343        let dir = cortexkit_test_support::ScratchDir::new("reload-roster-terminal-order");
11344        let record = dir.join("live-children.json");
11345        let supervisor = Supervisor::new_for_test(
11346            Arc::new(Registry::default()),
11347            RestartPolicy::new(0, Duration::ZERO),
11348        );
11349        let mut runtime = supervisor.runtime_config();
11350        runtime.child_roster.record_to(record.clone());
11351        let gate = Arc::new(super::ReloadExitRecordGate::default());
11352        runtime.test_reload_exit_record_gate = Some(Arc::clone(&gate));
11353        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11354        let spec = ModuleSpec {
11355            module_id: "reload-exit-roster".into(),
11356            program: fake_aft_stub_path(),
11357            args: Vec::new(),
11358            env: vec![("FAKE_AFT_EXIT_CODE".into(), "23".into())],
11359            reserved: false,
11360            reserved_prefixes: Vec::new(),
11361            protocol: ModuleProtocol::Subc,
11362            overlap: Default::default(),
11363        };
11364        let mut child = None;
11365        let reload = super::finish_reload_child(
11366            &spec,
11367            &runtime,
11368            &supervisor.registry,
11369            &supervisor.process_liveness,
11370            &snapshot,
11371            &mut child,
11372        );
11373        tokio::pin!(reload);
11374        tokio::select! {
11375            result = &mut reload => panic!("reload missed the before-record gate: {result:?}"),
11376            _ = gate.reached.notified() => {}
11377        }
11378        assert!(runtime
11379            .terminal_ring
11380            .lock()
11381            .unwrap()
11382            .snapshot()
11383            .entries
11384            .is_empty());
11385        assert_eq!(
11386            crate::live_children::read_record(&record).unwrap().len(),
11387            1,
11388            "shutdown must still wait for the reaped child until its terminal record exists"
11389        );
11390        runtime.child_roster.close();
11391        gate.resume.notify_one();
11392        assert!(reload.await.is_err());
11393        assert!(crate::live_children::read_record(&record)
11394            .unwrap()
11395            .is_empty());
11396        let history = runtime.terminal_ring.lock().unwrap().snapshot();
11397        assert_eq!(history.entries.len(), 1);
11398        assert_eq!(
11399            history.entries[0].disposition,
11400            TerminalDisposition::DaemonShutdown
11401        );
11402    }
11403
11404    /// Each restart-producing arm has its own state transition. Keeping their
11405    /// lifetime count assertions adjacent prevents a later new arm from silently
11406    /// spending budget without recording the historical restart.
11407    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11408    async fn every_restart_increment_path_advances_lifetime_count() {
11409        let supervisor = Supervisor::new_for_test(
11410            Arc::new(Registry::default()),
11411            RestartPolicy::new(1, Duration::ZERO),
11412        );
11413        let runtime = supervisor.runtime_config();
11414        let spec = ModuleSpec {
11415            module_id: "lifetime-increment-path".to_string(),
11416            program: PathBuf::from("/unused/lifetime-increment-path"),
11417            args: Vec::new(),
11418            env: Vec::new(),
11419            reserved: false,
11420            reserved_prefixes: Vec::new(),
11421            protocol: ModuleProtocol::Subc,
11422            overlap: Default::default(),
11423        };
11424
11425        let crash_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11426        assert!(matches!(
11427            on_child_exit(
11428                &spec,
11429                runtime.restart_policy,
11430                &supervisor.registry,
11431                &crash_snapshot,
11432                &runtime.terminal_ring,
11433                &runtime.spawn_events,
11434                &runtime.child_roster,
11435                ExitReport {
11436                    kind: ExitKind::Crash,
11437                    code: Some(1),
11438                    signal: None,
11439                    at_ms: 1,
11440                },
11441            )
11442            .await,
11443            NextAction::Restart { schedule: _ }
11444        ));
11445        let (crash_restarts, crash_lifetime) = {
11446            let state = lock_snapshot(&crash_snapshot).unwrap();
11447            (state.crash_restarts.len(), state.lifetime_restarts)
11448        };
11449        assert_eq!(crash_restarts, 1);
11450        assert_eq!(crash_lifetime, 1);
11451
11452        let health_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11453        let mut health_child = None;
11454        assert!(matches!(
11455            health_restart_child(
11456                &spec,
11457                &runtime,
11458                &supervisor.registry,
11459                &supervisor.process_liveness,
11460                &health_snapshot,
11461                &mut health_child,
11462                SupervisorHealthStatus::Failing,
11463                None,
11464                2,
11465            )
11466            .await,
11467            Ok(())
11468        ));
11469        assert!(health_child.is_none());
11470        assert!(lock_snapshot(&health_snapshot).unwrap().respawn_pending);
11471        assert!(runtime.scheduled_respawn.lock().unwrap().take().is_some());
11472        let (health_restarts, health_lifetime) = {
11473            let state = lock_snapshot(&health_snapshot).unwrap();
11474            (state.crash_restarts.len(), state.lifetime_restarts)
11475        };
11476        assert_eq!(health_restarts, 1);
11477        assert_eq!(health_lifetime, 1);
11478
11479        let reload_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11480        let mut reload_child = None;
11481        assert!(matches!(
11482            handle_reload_spawn_failure(
11483                &spec,
11484                &runtime,
11485                &supervisor.process_liveness,
11486                &reload_snapshot,
11487                &mut reload_child,
11488                "forced reload spawn failure".to_string(),
11489            )
11490            .await,
11491            Err(SuperviseError::ReloadFailed { .. })
11492        ));
11493        let (reload_restarts, reload_lifetime) = {
11494            let state = lock_snapshot(&reload_snapshot).unwrap();
11495            (state.crash_restarts.len(), state.lifetime_restarts)
11496        };
11497        assert_eq!(reload_restarts, 1);
11498        assert_eq!(reload_lifetime, 1);
11499    }
11500
11501    #[tokio::test]
11502    async fn deliberately_severed_live_child_records_lifetime_without_spending_restart_budget() {
11503        let supervisor = Supervisor::new_for_test(
11504            Arc::new(Registry::default()),
11505            RestartPolicy::new(3, Duration::ZERO),
11506        );
11507        let runtime = supervisor.runtime_config();
11508        let spec = ModuleSpec {
11509            module_id: "deliberately-severed".to_string(),
11510            program: PathBuf::from("/unused/deliberately-severed"),
11511            args: Vec::new(),
11512            env: Vec::new(),
11513            reserved: false,
11514            reserved_prefixes: Vec::new(),
11515            protocol: ModuleProtocol::Subc,
11516            overlap: Default::default(),
11517        };
11518        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11519        let process = ProcessIdentity {
11520            pid: 41,
11521            start_time: 101,
11522        };
11523        record_deliberate_severance(&snapshot, process).unwrap();
11524        let exit_report = apply_deliberate_severance_marker(
11525            &snapshot,
11526            Some(process),
11527            ExitReport {
11528                kind: ExitKind::Crash,
11529                code: Some(1),
11530                signal: None,
11531                at_ms: 1,
11532            },
11533        );
11534        assert_eq!(exit_report.kind, ExitKind::DeliberateSeverance);
11535
11536        assert!(matches!(
11537            on_child_exit(
11538                &spec,
11539                runtime.restart_policy,
11540                &supervisor.registry,
11541                &snapshot,
11542                &runtime.terminal_ring,
11543                &runtime.spawn_events,
11544                &runtime.child_roster,
11545                exit_report,
11546            )
11547            .await,
11548            NextAction::Restart { schedule: _ }
11549        ));
11550        let state = lock_snapshot(&snapshot).unwrap();
11551        assert_eq!(state.lifetime_restarts, 1);
11552        assert_eq!(state.crash_restarts.len(), 0);
11553    }
11554
11555    #[tokio::test]
11556    async fn genuine_crash_spends_restart_budget_and_records_lifetime() {
11557        let supervisor = Supervisor::new_for_test(
11558            Arc::new(Registry::default()),
11559            RestartPolicy::new(3, Duration::ZERO),
11560        );
11561        let runtime = supervisor.runtime_config();
11562        let spec = ModuleSpec {
11563            module_id: "genuine-crash".to_string(),
11564            program: PathBuf::from("/unused/genuine-crash"),
11565            args: Vec::new(),
11566            env: Vec::new(),
11567            reserved: false,
11568            reserved_prefixes: Vec::new(),
11569            protocol: ModuleProtocol::Subc,
11570            overlap: Default::default(),
11571        };
11572        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11573
11574        assert!(matches!(
11575            on_child_exit(
11576                &spec,
11577                runtime.restart_policy,
11578                &supervisor.registry,
11579                &snapshot,
11580                &runtime.terminal_ring,
11581                &runtime.spawn_events,
11582                &runtime.child_roster,
11583                ExitReport {
11584                    kind: ExitKind::Crash,
11585                    code: Some(1),
11586                    signal: None,
11587                    at_ms: 1,
11588                },
11589            )
11590            .await,
11591            NextAction::Restart { schedule: _ }
11592        ));
11593        let state = lock_snapshot(&snapshot).unwrap();
11594        assert_eq!(state.lifetime_restarts, 1);
11595        assert_eq!(state.crash_restarts.len(), 1);
11596    }
11597
11598    fn crash_exit_report(at_ms: u64) -> ExitReport {
11599        ExitReport {
11600            kind: ExitKind::Crash,
11601            code: Some(1),
11602            signal: None,
11603            at_ms,
11604        }
11605    }
11606
11607    fn windowed_crash_spec(module_id: &str) -> ModuleSpec {
11608        ModuleSpec {
11609            module_id: module_id.to_string(),
11610            program: PathBuf::from("/unused").join(module_id),
11611            args: Vec::new(),
11612            env: Vec::new(),
11613            reserved: false,
11614            reserved_prefixes: Vec::new(),
11615            protocol: ModuleProtocol::Subc,
11616            overlap: Default::default(),
11617        }
11618    }
11619
11620    /// A real crash loop still stops. Three crashes with nothing aging out spend
11621    /// a budget of two and the third respawn is refused, and both surfaces an
11622    /// operator has -- the log line and the retained terminal record -- name the
11623    /// window rather than only the cap, because `max_restarts=2` alone is what
11624    /// this budget used to mean.
11625    #[tokio::test]
11626    async fn three_crashes_inside_the_window_stop_the_module_and_name_the_window() {
11627        let (logs, _guard) = crate::router::test_log::log_capture(tracing::Level::ERROR);
11628        let supervisor = Supervisor::new_for_test(
11629            Arc::new(Registry::default()),
11630            RestartPolicy::new(2, Duration::ZERO),
11631        );
11632        let runtime = supervisor.runtime_config();
11633        let spec = windowed_crash_spec("crash-loop-in-window");
11634        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11635
11636        for attempt in 1..=2 {
11637            assert!(
11638                matches!(
11639                    on_child_exit(
11640                        &spec,
11641                        runtime.restart_policy,
11642                        &supervisor.registry,
11643                        &snapshot,
11644                        &runtime.terminal_ring,
11645                        &runtime.spawn_events,
11646                        &runtime.child_roster,
11647                        crash_exit_report(attempt),
11648                    )
11649                    .await,
11650                    NextAction::Restart { schedule: _ }
11651                ),
11652                "crash {attempt} is inside the budget and must respawn"
11653            );
11654        }
11655
11656        assert!(matches!(
11657            on_child_exit(
11658                &spec,
11659                runtime.restart_policy,
11660                &supervisor.registry,
11661                &snapshot,
11662                &runtime.terminal_ring,
11663                &runtime.spawn_events,
11664                &runtime.child_roster,
11665                crash_exit_report(3),
11666            )
11667            .await,
11668            NextAction::Stop { .. }
11669        ));
11670
11671        {
11672            let state = lock_snapshot(&snapshot).unwrap();
11673            assert_eq!(state.state, ModuleState::Failed);
11674            assert_eq!(state.crash_restarts.len(), 2);
11675            assert_eq!(state.lifetime_restarts, 2);
11676        }
11677
11678        let history = runtime
11679            .terminal_ring
11680            .lock()
11681            .expect("terminal ring is not poisoned")
11682            .snapshot();
11683        let last = history
11684            .entries
11685            .last()
11686            .expect("the refused crash is retained");
11687        assert_eq!(last.disposition, TerminalDisposition::Failed);
11688        assert_eq!(
11689            last.disposition_detail.as_deref(),
11690            Some("crash budget exhausted: max_restarts=2 within window_secs=600")
11691        );
11692
11693        let captured = crate::router::test_log::captured_logs(&logs);
11694        assert!(
11695            captured.contains("crash budget exhausted: max_restarts=2 within window_secs=600"),
11696            "the stop must be logged with its window: {captured}"
11697        );
11698    }
11699
11700    /// The rate, stated as a test: three crashes where the first has aged past
11701    /// the window are two crashes as far as the budget is concerned, so the
11702    /// third respawn is allowed and the ring holds only the two recent ones.
11703    ///
11704    /// This is the case a lifetime counter got wrong -- and the case the daemon
11705    /// now hits routinely, since a module exits non-zero every time its
11706    /// connection to the daemon drops.
11707    #[tokio::test]
11708    async fn a_crash_older_than_the_window_frees_its_slot_for_a_later_crash() {
11709        let supervisor = Supervisor::new_for_test(
11710            Arc::new(Registry::default()),
11711            RestartPolicy::new(2, Duration::ZERO),
11712        );
11713        let runtime = supervisor.runtime_config();
11714        let spec = windowed_crash_spec("crash-across-windows");
11715        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11716
11717        for attempt in 1..=2 {
11718            assert!(matches!(
11719                on_child_exit(
11720                    &spec,
11721                    runtime.restart_policy,
11722                    &supervisor.registry,
11723                    &snapshot,
11724                    &runtime.terminal_ring,
11725                    &runtime.spawn_events,
11726                    &runtime.child_roster,
11727                    crash_exit_report(attempt),
11728                )
11729                .await,
11730                NextAction::Restart { schedule: _ }
11731            ));
11732        }
11733
11734        // The oldest crash moves out of the window; nothing else about the
11735        // module changes.
11736        update_snapshot(&snapshot, Some(&spec.module_id), |state| {
11737            age_oldest_crash_restart_out_of_window(state, runtime.restart_policy.window);
11738        })
11739        .unwrap();
11740
11741        assert!(
11742            matches!(
11743                on_child_exit(
11744                    &spec,
11745                    runtime.restart_policy,
11746                    &supervisor.registry,
11747                    &snapshot,
11748                    &runtime.terminal_ring,
11749                    &runtime.spawn_events,
11750                    &runtime.child_roster,
11751                    crash_exit_report(3),
11752                )
11753                .await,
11754                NextAction::Restart { schedule: _ }
11755            ),
11756            "a crash older than the window must not hold a budget slot"
11757        );
11758
11759        let state = lock_snapshot(&snapshot).unwrap();
11760        assert_eq!(state.state, ModuleState::Restarting);
11761        assert_eq!(
11762            state.crash_restarts.len(),
11763            2,
11764            "the aged instant is dropped and the new one takes its place"
11765        );
11766        assert_eq!(
11767            state.lifetime_restarts, 3,
11768            "the ledger counts every restart, including the ones the window forgot"
11769        );
11770    }
11771
11772    /// An operator restart hands the budget back whole, and the ledger keeps
11773    /// counting. Those are different questions -- "how close is this module to
11774    /// being stopped" and "how many times has it been replaced" -- and the
11775    /// operator action answers only the first.
11776    #[tokio::test]
11777    async fn an_operator_restart_clears_the_ring_and_leaves_the_ledger_alone() {
11778        let supervisor = Supervisor::new_for_test(
11779            Arc::new(Registry::default()),
11780            RestartPolicy::new(2, Duration::ZERO),
11781        );
11782        let runtime = supervisor.runtime_config();
11783        let spec = windowed_crash_spec("operator-cleared-budget");
11784        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11785
11786        for attempt in 1..=2 {
11787            assert!(matches!(
11788                on_child_exit(
11789                    &spec,
11790                    runtime.restart_policy,
11791                    &supervisor.registry,
11792                    &snapshot,
11793                    &runtime.terminal_ring,
11794                    &runtime.spawn_events,
11795                    &runtime.child_roster,
11796                    crash_exit_report(attempt),
11797                )
11798                .await,
11799                NextAction::Restart { schedule: _ }
11800            ));
11801        }
11802
11803        reset_restart_count(&snapshot, &spec.module_id).unwrap();
11804        {
11805            let state = lock_snapshot(&snapshot).unwrap();
11806            assert!(
11807                state.crash_restarts.is_empty(),
11808                "an operator restart returns the full budget"
11809            );
11810            assert_eq!(
11811                state.lifetime_restarts, 2,
11812                "clearing the budget must not unmake the crashes"
11813            );
11814        }
11815
11816        assert!(
11817            matches!(
11818                on_child_exit(
11819                    &spec,
11820                    runtime.restart_policy,
11821                    &supervisor.registry,
11822                    &snapshot,
11823                    &runtime.terminal_ring,
11824                    &runtime.spawn_events,
11825                    &runtime.child_roster,
11826                    crash_exit_report(3),
11827                )
11828                .await,
11829                NextAction::Restart { schedule: _ }
11830            ),
11831            "the cleared budget must be spendable again"
11832        );
11833        let state = lock_snapshot(&snapshot).unwrap();
11834        assert_eq!(state.crash_restarts.len(), 1);
11835        assert_eq!(state.lifetime_restarts, 3);
11836    }
11837
11838    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11839    async fn severance_marker_for_a_dead_child_does_not_label_its_successor() {
11840        let severed = ProcessIdentity {
11841            pid: 41,
11842            start_time: 101,
11843        };
11844        let successor = ProcessIdentity {
11845            pid: 41,
11846            start_time: 202,
11847        };
11848        let module = module_with_recovery_snapshot(ModuleState::Running, true, 0);
11849        update_snapshot(&module.inner.snapshot, Some("recovery-snapshot"), |state| {
11850            state.pid = Some(successor.pid);
11851            state.process_start_time = Some(successor.start_time);
11852        })
11853        .unwrap();
11854        assert!(!module.record_deliberate_severance(severed).unwrap());
11855
11856        let exit_report = apply_deliberate_severance_marker(
11857            &module.inner.snapshot,
11858            Some(successor),
11859            ExitReport {
11860                kind: ExitKind::Crash,
11861                code: Some(1),
11862                signal: None,
11863                at_ms: 1,
11864            },
11865        );
11866
11867        assert_eq!(exit_report.kind, ExitKind::Crash);
11868    }
11869
11870    #[tokio::test]
11871    async fn drain_reap_marks_deliberate_severance_and_records_lifetime_without_budget() {
11872        let registry = Registry::default();
11873        let supervisor = Supervisor::new_for_test(
11874            Arc::new(Registry::default()),
11875            RestartPolicy::new(3, Duration::ZERO),
11876        );
11877        let runtime = supervisor.runtime_config();
11878        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11879        let spec = ModuleSpec {
11880            module_id: "drain-deliberate-severance".to_string(),
11881            program: fake_aft_stub_path(),
11882            args: Vec::new(),
11883            env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
11884            reserved: false,
11885            reserved_prefixes: Vec::new(),
11886            protocol: ModuleProtocol::Subc,
11887            overlap: Default::default(),
11888        };
11889        let mut child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
11890        let process = ProcessIdentity {
11891            pid: 41,
11892            start_time: 101,
11893        };
11894        child.process_identity = Some(process);
11895        update_snapshot(&snapshot, Some(&spec.module_id), |state| {
11896            state.pid = Some(process.pid);
11897            state.process_start_time = Some(process.start_time);
11898        })
11899        .unwrap();
11900        record_deliberate_severance(&snapshot, process).unwrap();
11901
11902        drain_child_to_state(
11903            &spec.module_id,
11904            spec.protocol,
11905            // The child exits on its own; no signal may change the exit this
11906            // test classifies.
11907            StopNotice::SentOverConnection,
11908            &registry,
11909            None,
11910            &snapshot,
11911            &runtime.terminal_ring,
11912            &runtime.spawn_events,
11913            child,
11914            Duration::from_secs(1),
11915            ModuleState::Stopped,
11916            Some(false),
11917        )
11918        .await
11919        .unwrap();
11920
11921        let state = lock_snapshot(&snapshot).unwrap();
11922        assert_eq!(
11923            state.last_exit.as_ref().map(|exit| exit.kind),
11924            Some(ExitKind::DeliberateSeverance)
11925        );
11926        assert_eq!(state.lifetime_restarts, 1);
11927        assert_eq!(state.crash_restarts.len(), 0);
11928        drop(state);
11929        let history = runtime.terminal_ring.lock().unwrap().snapshot();
11930        assert_eq!(
11931            history.entries[0].exit_kind,
11932            subc_control::TerminalExitKind::DeliberateSeverance
11933        );
11934    }
11935
11936    #[tokio::test]
11937    async fn ordinary_drain_reap_does_not_record_a_lifetime_restart() {
11938        let registry = Registry::default();
11939        let supervisor = Supervisor::new_for_test(
11940            Arc::new(Registry::default()),
11941            RestartPolicy::new(3, Duration::ZERO),
11942        );
11943        let runtime = supervisor.runtime_config();
11944        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11945        let spec = ModuleSpec {
11946            module_id: "ordinary-drain".to_string(),
11947            program: fake_aft_stub_path(),
11948            args: Vec::new(),
11949            env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
11950            reserved: false,
11951            reserved_prefixes: Vec::new(),
11952            protocol: ModuleProtocol::Subc,
11953            overlap: Default::default(),
11954        };
11955        let child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
11956
11957        drain_child_to_state(
11958            &spec.module_id,
11959            spec.protocol,
11960            // The child exits on its own; no signal may change the exit this
11961            // test classifies.
11962            StopNotice::SentOverConnection,
11963            &registry,
11964            None,
11965            &snapshot,
11966            &runtime.terminal_ring,
11967            &runtime.spawn_events,
11968            child,
11969            Duration::from_secs(1),
11970            ModuleState::Stopped,
11971            Some(false),
11972        )
11973        .await
11974        .unwrap();
11975
11976        let state = lock_snapshot(&snapshot).unwrap();
11977        assert_eq!(
11978            state.last_exit.as_ref().map(|exit| exit.kind),
11979            Some(ExitKind::Crash)
11980        );
11981        assert_eq!(state.lifetime_restarts, 0);
11982        assert_eq!(state.crash_restarts.len(), 0);
11983    }
11984
11985    #[test]
11986    fn fatal_connection_teardown_cannot_arm_a_marker_for_a_surviving_process() {
11987        // The server's generic fatal-routing branch only knows that the
11988        // connection failed; it does not know that the daemon deliberately
11989        // initiated a process-killing severance. Keep this seam explicit so a
11990        // future connection error path cannot silently reintroduce the stale
11991        // exemption that mislabels a later genuine crash.
11992        assert!(!include_str!("server.rs")
11993            .contains("router.record_deliberate_connection_severance(ctx.connection_id)"));
11994    }
11995
11996    /// The `route.closed` `drained` value must be the quiescence wait's own
11997    /// measurement (`Ok`), never invented -- except on `Err`, where there is no
11998    /// measurement at all and `false` is the one honest constant. This is the exact
11999    /// logic `begin_forwarding_drain_with` now applies before sending `route.closed`
12000    /// on every return path, including the one that used to return early via `?`
12001    /// with `route.closing` already sent and no `route.closed` ever following.
12002    #[test]
12003    fn drained_after_quiescence_wait_passes_ok_through_and_forces_false_on_err() {
12004        assert!(drained_after_quiescence_wait(&Ok(true)));
12005        assert!(!drained_after_quiescence_wait(&Ok(false)));
12006        assert!(!drained_after_quiescence_wait(&Err(
12007            SuperviseError::StatePoisoned { module_id: None }
12008        )));
12009    }
12010
12011    /// `supervise_loop`'s `wait()`-error arm now calls `record_terminal` like every
12012    /// other exit path does, so a module whose child `wait()` itself errored (e.g.
12013    /// already reaped out-of-band) still leaves a terminal record rather than none
12014    /// at all. Triggering the real `wait()` I/O error from an integration test would
12015    /// need a genuine already-reaped-child race, which is OS-specific and not
12016    /// something this suite attempts elsewhere; this test instead verifies the
12017    /// record produced for that arm end-to-end through the real `TerminalRing`, and
12018    /// the call site itself is verified by inspection to sit in that exact arm.
12019    #[test]
12020    fn wait_error_exit_report_records_a_failed_terminal_with_no_code_or_signal() {
12021        let ring = Arc::new(Mutex::new(TerminalRing::new(
12022            TerminalRingConfig::default(),
12023            0,
12024        )));
12025        record_wait_error_terminal("wait-error", &ring, &super::SpawnEventFeed::default());
12026
12027        let snapshot = ring.lock().unwrap().snapshot();
12028        assert_eq!(snapshot.entries.len(), 1);
12029        let entry = &snapshot.entries[0];
12030        assert_eq!(entry.exit_code, None);
12031        assert_eq!(entry.exit_signal, None);
12032        assert_eq!(entry.disposition, TerminalDisposition::Failed);
12033    }
12034
12035    #[test]
12036    fn wait_error_exit_path_preserves_spawn_event_density() {
12037        let feed = super::SpawnEventFeed::default();
12038        feed.configure_incarnation("wait-error-density".to_string());
12039        feed.emit_spawned("wait-error", 41, 1);
12040        let ring = Arc::new(Mutex::new(TerminalRing::new(
12041            TerminalRingConfig::default(),
12042            0,
12043        )));
12044
12045        record_wait_error_terminal("wait-error", &ring, &feed);
12046        feed.emit_spawned("after-wait-error", 42, 2);
12047
12048        let state = feed.0.lock().unwrap();
12049        let sequences = state
12050            .events
12051            .iter()
12052            .map(|event| event.cursor.seq)
12053            .collect::<Vec<_>>();
12054        assert_eq!(sequences, vec![1, 2, 3]);
12055        assert_eq!(state.events[1].kind, SpawnEventKind::Exited);
12056        assert_eq!(state.events[1].exit_code, None);
12057        assert_eq!(state.events[1].exit_signal, None);
12058    }
12059
12060    /// Pins the report's `kind` too: the wait-error arm treats an unwaitable child
12061    /// as a crash (matching `fail_snapshot`'s `Failed` disposition for this arm),
12062    /// not a clean exit it never actually observed.
12063    #[test]
12064    fn wait_error_exit_report_is_classified_as_a_crash() {
12065        assert_eq!(wait_error_exit_report().kind, ExitKind::Crash);
12066    }
12067}
12068
12069#[cfg(all(test, unix))]
12070mod health_event_tests {
12071    use super::*;
12072    use cortexkit_test_support::ScratchDir;
12073    use subc_protocol::{manifest::Concurrency, session::ModuleControlResponse};
12074
12075    struct Actor {
12076        module: SupervisedModule,
12077        registry: Arc<Registry>,
12078        forwarding: Arc<ForwardingTable>,
12079        spec: ModuleSpec,
12080        health: HealthConfig,
12081        rx: mpsc::Receiver<crate::router::OutboundFrame>,
12082        _home: ScratchDir,
12083    }
12084
12085    async fn wait_for<T>(reason: &str, mut observe: impl FnMut() -> Option<T>) -> T {
12086        // OS exec and socket readiness are real I/O. Stay runnable while waiting
12087        // for an observed condition, rather than letting paused time auto-advance
12088        // deadlines or assuming a fixed number of yields finishes the work.
12089        let deadline = std::time::Instant::now() + Duration::from_secs(10);
12090        let tick = Instant::now();
12091        loop {
12092            if let Some(value) = observe() {
12093                assert_eq!(Instant::now(), tick, "{reason} must not wait for a timer");
12094                return value;
12095            }
12096            assert!(
12097                std::time::Instant::now() < deadline,
12098                "timed out waiting for {reason}"
12099            );
12100            tokio::task::yield_now().await;
12101        }
12102    }
12103
12104    impl Actor {
12105        async fn start(protocol: ModuleProtocol, cadence: Duration) -> Self {
12106            let home = ScratchDir::new("health-events");
12107            let registry = Arc::new(Registry::default());
12108            let forwarding = Arc::new(ForwardingTable::default());
12109            let health = HealthConfig {
12110                cadence,
12111                deadline: Duration::from_secs(3600),
12112                ..HealthConfig::default()
12113            };
12114            let supervisor =
12115                Supervisor::new_for_test(registry.clone(), RestartPolicy::new(3, Duration::ZERO))
12116                    .with_forwarding(forwarding.clone())
12117                    .with_health_config(health.clone());
12118            let spec = ModuleSpec {
12119                module_id: "event-health-child".into(),
12120                program: PathBuf::from("/bin/sh"),
12121                // The process stays alive but has no real bus peer. Registrations
12122                // below exercise the same registry writes as accepted HELLOs.
12123                args: vec!["-c".into(), "exec sleep 600".into()],
12124                env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
12125                    .into_iter()
12126                    .map(|key| (key.into(), home.path().display().to_string()))
12127                    .collect(),
12128                reserved: false,
12129                reserved_prefixes: vec![],
12130                protocol,
12131                overlap: Default::default(),
12132            };
12133            let module = supervisor.spawn(spec.clone()).unwrap();
12134            let (_, rx) = mpsc::channel(8);
12135            let actor = Self {
12136                module,
12137                registry,
12138                forwarding,
12139                spec,
12140                health,
12141                rx,
12142                _home: home,
12143            };
12144            actor
12145                .wait_for_select(|checkpoint| checkpoint.generation > 0)
12146                .await;
12147            actor
12148        }
12149
12150        fn turns(&self) -> u64 {
12151            lock_snapshot(&self.module.inner.snapshot)
12152                .unwrap()
12153                .actor_turns
12154        }
12155
12156        async fn wait_for_select(
12157            &self,
12158            expected: impl Fn(&ActorSelectCheckpoint) -> bool,
12159        ) -> ActorSelectCheckpoint {
12160            wait_for("actor select checkpoint", || {
12161                let state = lock_snapshot(&self.module.inner.snapshot).unwrap();
12162                state.actor_select.filter(|checkpoint| {
12163                    checkpoint.generation == state.spawn_generation
12164                        && state.state == ModuleState::Running
12165                        && state.process_alive
12166                        && state.reported_pid().is_some()
12167                        && expected(checkpoint)
12168                })
12169            })
12170            .await
12171        }
12172
12173        fn hello(&mut self, connection: u64, health: bool) {
12174            let manifest =
12175                subc_protocol::manifest::ModuleManifest::builder(&self.spec.module_id, "1.0.0")
12176                    .protocol_ver(subc_protocol::PROTOCOL_VERSION)
12177                    .build();
12178            let connection = ConnectionId::new(connection);
12179            let (tx, rx) = mpsc::channel(8);
12180            self.rx = rx;
12181            self.forwarding
12182                .register_module_connection(
12183                    connection,
12184                    self.spec.module_id.clone(),
12185                    subc_protocol::PROTOCOL_VERSION,
12186                    Concurrency::ModuleManaged,
12187                    FrameSink::new(tx),
12188                )
12189                .unwrap();
12190            self.registry
12191                .register_with_control_ops(
12192                    manifest,
12193                    subc_protocol::PROTOCOL_VERSION,
12194                    connection,
12195                    if health {
12196                        vec![MODULE_CONTROL_OP_HEALTH_CHECK.into()]
12197                    } else {
12198                        vec![]
12199                    },
12200                )
12201                .unwrap();
12202        }
12203
12204        async fn probe(&mut self) -> crate::router::OutboundFrame {
12205            let frame = wait_for("outbound health probe", || match self.rx.try_recv() {
12206                Ok(frame) => Some(frame),
12207                Err(mpsc::error::TryRecvError::Empty) => None,
12208                Err(mpsc::error::TryRecvError::Disconnected) => panic!("health peer disconnected"),
12209            })
12210            .await;
12211            let body: Value = serde_json::from_slice(&frame.body).unwrap();
12212            assert_eq!(body["op"], MODULE_CONTROL_OP_HEALTH_CHECK);
12213            frame
12214        }
12215
12216        fn no_probe(&mut self) {
12217            assert!(matches!(
12218                self.rx.try_recv(),
12219                Err(mpsc::error::TryRecvError::Empty)
12220            ));
12221        }
12222
12223        fn answer(&self, connection: u64, frame: crate::router::OutboundFrame) {
12224            self.forwarding
12225                .complete_module_control_rpc(
12226                    ConnectionId::new(connection),
12227                    frame.header.corr,
12228                    Some(MODULE_CONTROL_OP_HEALTH_CHECK),
12229                    ModuleControlRpcOutcome::Response(ModuleControlResponse::HealthCheck {
12230                        status: HealthStatus::Ok,
12231                        detail: None,
12232                        metrics: None,
12233                    }),
12234                )
12235                .unwrap();
12236        }
12237    }
12238
12239    #[tokio::test(start_paused = true)]
12240    async fn no_health_children_park_for_sixty_seconds() {
12241        let none = Actor::start(ModuleProtocol::None, Duration::from_secs(30)).await;
12242        let unregistered = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12243        let mut unadvertised = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12244        unadvertised.hello(1, false);
12245        // Include any one-off registration wake in the budget; receiving a
12246        // no-health HELLO must not turn a parked child into a polling child.
12247        // Advance in 10 ms steps so the old poll really executes ~6,000 turns;
12248        // a single 60 s jump would only observe one expired timer.
12249        for _ in 0..6000 {
12250            tokio::time::advance(Duration::from_millis(10)).await;
12251            tokio::task::yield_now().await;
12252        }
12253        for actor in [&none, &unregistered, &unadvertised] {
12254            assert!(
12255                actor.turns() <= 5,
12256                "no-health actor woke {} times",
12257                actor.turns()
12258            );
12259            assert_eq!(actor.module.state().unwrap(), ModuleState::Running);
12260        }
12261    }
12262
12263    #[tokio::test(start_paused = true)]
12264    async fn late_hello_starts_a_due_probe_in_the_notification_tick() {
12265        // With a zero cadence the first probe is due at once, so this measures
12266        // only how fast the registration notification starts health probing,
12267        // not the normal 30-33 s wait before a first probe (checked below).
12268        let mut actor = Actor::start(ModuleProtocol::Subc, Duration::ZERO).await;
12269        let tick = Instant::now();
12270        actor.hello(1, true);
12271        actor.probe().await;
12272        assert_eq!(Instant::now(), tick, "HELLO must not wait for a poll");
12273    }
12274
12275    #[tokio::test(start_paused = true)]
12276    async fn hello_keeps_the_jittered_cadence_and_catalog_updates_do_not_reset_it() {
12277        let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12278        let hello_tick = Instant::now();
12279        actor.hello(1, true);
12280        let armed = actor
12281            .wait_for_select(|checkpoint| {
12282                checkpoint.registered_connection == Some(ConnectionId::new(1))
12283                    && checkpoint.next_probe_at.is_some()
12284            })
12285            .await;
12286        let delay = jittered_health_delay(&actor.spec.module_id, 0, actor.health.cadence);
12287        assert!((Duration::from_secs(30)..Duration::from_secs(33)).contains(&delay));
12288        let first_deadline = hello_tick + delay;
12289        assert_eq!(armed.next_probe_at, Some(first_deadline));
12290        tokio::time::advance(Duration::from_secs(29)).await;
12291        let turns = actor.turns();
12292        actor
12293            .registry
12294            .replace_catalog_for_connection(ConnectionId::new(1), vec![], None, Some(true))
12295            .unwrap();
12296        let updated = actor
12297            .wait_for_select(|checkpoint| checkpoint.turn > turns)
12298            .await;
12299        assert_eq!(
12300            updated.next_probe_at,
12301            Some(first_deadline),
12302            "catalog update must not reset cadence"
12303        );
12304        actor.no_probe();
12305        tokio::time::advance(first_deadline - Instant::now()).await;
12306        let frame = actor.probe().await;
12307        actor.answer(1, frame);
12308        let next = jittered_health_delay(&actor.spec.module_id, 1, actor.health.cadence);
12309        let next_deadline = Instant::now() + next;
12310        let rearmed = actor
12311            .wait_for_select(|checkpoint| {
12312                checkpoint
12313                    .next_probe_at
12314                    .is_some_and(|deadline| deadline > first_deadline)
12315            })
12316            .await;
12317        assert_eq!(rearmed.next_probe_at, Some(next_deadline));
12318        tokio::time::advance(next - Duration::from_nanos(1)).await;
12319        actor.no_probe();
12320        tokio::time::advance(Duration::from_nanos(1)).await;
12321        actor.probe().await;
12322    }
12323
12324    #[tokio::test(start_paused = true)]
12325    async fn disconnect_disarms_and_reconnect_rearms_health() {
12326        let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12327        actor.hello(1, true);
12328        actor
12329            .wait_for_select(|checkpoint| {
12330                checkpoint.registered_connection == Some(ConnectionId::new(1))
12331                    && checkpoint.next_probe_at.is_some()
12332            })
12333            .await;
12334        let turns = actor.turns();
12335        let tick = Instant::now();
12336        actor
12337            .registry
12338            .deregister_connection(ConnectionId::new(1))
12339            .unwrap();
12340        let disarmed = actor
12341            .wait_for_select(|checkpoint| {
12342                checkpoint.turn > turns && checkpoint.registered_connection.is_none()
12343            })
12344            .await;
12345        assert_eq!(disarmed.next_probe_at, None);
12346        assert_eq!(disarmed.wake_after, None);
12347        assert_eq!(
12348            Instant::now(),
12349            tick,
12350            "disconnect must disarm in the notification tick"
12351        );
12352        tokio::time::advance(Duration::from_secs(60)).await;
12353        actor.no_probe();
12354        let reconnect_tick = Instant::now();
12355        actor.hello(2, true);
12356        let rearmed = actor
12357            .wait_for_select(|checkpoint| {
12358                checkpoint.registered_connection == Some(ConnectionId::new(2))
12359                    && checkpoint.next_probe_at.is_some()
12360            })
12361            .await;
12362        let delay = jittered_health_delay(&actor.spec.module_id, 0, actor.health.cadence);
12363        assert_eq!(rearmed.next_probe_at, Some(reconnect_tick + delay));
12364        tokio::time::advance(delay).await;
12365        actor.probe().await;
12366    }
12367
12368    #[tokio::test(start_paused = true)]
12369    async fn reregistration_without_health_disarms_the_old_schedule() {
12370        let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12371        actor.hello(1, true);
12372        actor
12373            .wait_for_select(|checkpoint| {
12374                checkpoint.registered_connection == Some(ConnectionId::new(1))
12375                    && checkpoint.next_probe_at.is_some()
12376            })
12377            .await;
12378        actor
12379            .registry
12380            .deregister_connection(ConnectionId::new(1))
12381            .unwrap();
12382        actor.hello(2, false);
12383        let disarmed = actor
12384            .wait_for_select(|checkpoint| {
12385                checkpoint.registered_connection == Some(ConnectionId::new(2))
12386            })
12387            .await;
12388        assert_eq!(disarmed.next_probe_at, None);
12389        assert_eq!(disarmed.wake_after, None);
12390        tokio::time::advance(Duration::from_secs(60)).await;
12391        actor.no_probe();
12392    }
12393
12394    #[tokio::test(start_paused = true)]
12395    async fn restart_waits_for_its_own_hello_before_rearming_health() {
12396        let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12397        actor.hello(1, true);
12398        let armed = actor
12399            .wait_for_select(|checkpoint| {
12400                checkpoint.registered_connection == Some(ConnectionId::new(1))
12401                    && checkpoint.next_probe_at.is_some()
12402            })
12403            .await;
12404        // Simulate the old process's connection closing before the restart
12405        // tears the process down. The replacement process has started but has
12406        // not sent its HELLO, so it is not registered.
12407        actor
12408            .registry
12409            .deregister_connection(ConnectionId::new(1))
12410            .unwrap();
12411        actor.module.restart(Some(0)).await.unwrap();
12412        let parked = actor
12413            .wait_for_select(|checkpoint| {
12414                checkpoint.generation > armed.generation
12415                    && checkpoint.registered_connection.is_none()
12416                    && checkpoint.next_probe_at.is_none()
12417            })
12418            .await;
12419        assert_eq!(parked.wake_after, None);
12420        actor.rx.close();
12421        // Drain the stop notice the restart queued for the old process's connection,
12422        // so it isn't mistaken for traffic to the replacement.
12423        while actor.rx.try_recv().is_ok() {}
12424        let turns = actor.turns();
12425        tokio::time::advance(Duration::from_secs(60)).await;
12426        let after = actor.turns();
12427        eprintln!(
12428            "restart park turns: before={turns} after={after} delta={}",
12429            after - turns
12430        );
12431        assert_eq!(after, turns, "replacement must park before HELLO");
12432        let hello_tick = Instant::now();
12433        actor.hello(2, true);
12434        let rearmed = actor
12435            .wait_for_select(|checkpoint| {
12436                checkpoint.registered_connection == Some(ConnectionId::new(2))
12437                    && checkpoint.next_probe_at.is_some()
12438            })
12439            .await;
12440        let delay = jittered_health_delay(&actor.spec.module_id, 0, actor.health.cadence);
12441        assert_eq!(rearmed.next_probe_at, Some(hello_tick + delay));
12442        tokio::time::advance(delay).await;
12443        actor.probe().await;
12444    }
12445
12446    #[tokio::test(start_paused = true)]
12447    async fn rescan_adds_and_removes_http_health_without_polling() {
12448        use tokio::{
12449            io::{AsyncReadExt, AsyncWriteExt},
12450            net::TcpListener,
12451        };
12452        let mut actor = Actor::start(ModuleProtocol::None, Duration::from_secs(30)).await;
12453        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
12454        actor.health.http = Some(format!("http://{}/health", listener.local_addr().unwrap()));
12455        // One millisecond separates successive probes without a polling delay.
12456        actor.health.cadence = Duration::from_millis(1);
12457        let (started_tx, mut started_rx) = mpsc::channel(8);
12458        let peer = tokio::spawn(async move {
12459            loop {
12460                let (mut socket, _) = listener.accept().await.unwrap();
12461                let mut request = [0; 1024];
12462                assert!(socket.read(&mut request).await.unwrap() > 0);
12463                started_tx.send(()).await.unwrap();
12464                socket
12465                    .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
12466                    .await
12467                    .unwrap();
12468            }
12469        });
12470        let tick = Instant::now();
12471        let turns = actor.turns();
12472        actor
12473            .module
12474            .update_configuration(actor.spec.clone(), actor.health.clone(), None)
12475            .await
12476            .unwrap();
12477        let armed = actor
12478            .wait_for_select(|checkpoint| {
12479                checkpoint.turn > turns && checkpoint.next_probe_at.is_some()
12480            })
12481            .await;
12482        assert_eq!(armed.next_probe_at, Some(tick + Duration::from_millis(1)));
12483        assert_eq!(Instant::now(), tick);
12484        tokio::time::advance(Duration::from_millis(1)).await;
12485        let mut started = false;
12486        wait_for("successful HTTP probe", || {
12487            started |= started_rx.try_recv().is_ok();
12488            (started && actor.module.status().unwrap().health.status == SupervisorHealthStatus::Ok)
12489                .then_some(())
12490        })
12491        .await;
12492        assert!(started, "added HTTP check must start at its first deadline");
12493        assert_eq!(
12494            actor.module.status().unwrap().health.status,
12495            SupervisorHealthStatus::Ok
12496        );
12497        assert_eq!(Instant::now(), tick + Duration::from_millis(1));
12498        actor.health.http = None;
12499        let turns = actor.turns();
12500        actor
12501            .module
12502            .update_configuration(actor.spec.clone(), actor.health.clone(), None)
12503            .await
12504            .unwrap();
12505        let parked = actor
12506            .wait_for_select(|checkpoint| {
12507                checkpoint.turn > turns && checkpoint.next_probe_at.is_none()
12508            })
12509            .await;
12510        assert_eq!(parked.wake_after, None);
12511        let turns = actor.turns();
12512        tokio::time::advance(Duration::from_secs(60)).await;
12513        assert_eq!(
12514            actor.turns(),
12515            turns,
12516            "removed HTTP check must park the actor"
12517        );
12518        assert!(
12519            started_rx.try_recv().is_err(),
12520            "removed HTTP check must stay disarmed"
12521        );
12522        assert_eq!(
12523            actor.module.status().unwrap().health,
12524            ModuleHealthStatus::default()
12525        );
12526        peer.abort();
12527    }
12528}
12529
12530#[cfg(test)]
12531mod health_evidence_tests {
12532    use super::{HealthProbeError, HealthProbeEvidence};
12533    use std::collections::HashSet;
12534
12535    /// The evidential asymmetry, asserted rather than described.
12536    ///
12537    /// Exactly ONE observation is proof a module cannot serve, and the one that
12538    /// fires under CPU starvation is not it. Before the split, all fifteen
12539    /// construction sites collapsed into a single String, so a timeout carried the
12540    /// same weight as a dead lane -- which is how a healthy module was restarted
12541    /// three times in one day.
12542    #[test]
12543    fn only_a_dead_lane_is_proof_of_death() {
12544        assert!(HealthProbeError::lane_dead("gone").is_proof_of_death());
12545        // Three non-proof classes, each for a different reason: silence is
12546        // consistent with health, a bad answer proves the module ALIVE, and a
12547        // daemon-side fault never reached the module at all.
12548        assert!(!HealthProbeError::no_answer("timed out").is_proof_of_death());
12549        assert!(!HealthProbeError::bad_answer("garbage").is_proof_of_death());
12550        assert!(!HealthProbeError::misconfigured("no table").is_proof_of_death());
12551    }
12552
12553    /// Labels must be distinct, or the operator-facing distinction is cosmetic.
12554    ///
12555    /// A shared label renders two different observations identically in the line an
12556    /// operator reads after an unexplained restart -- the exact confusion this
12557    /// change removes.
12558    #[test]
12559    fn every_evidence_class_has_a_distinct_label() {
12560        let labels = [
12561            HealthProbeError::lane_dead("").label(),
12562            HealthProbeError::no_answer("").label(),
12563            HealthProbeError::bad_answer("").label(),
12564            HealthProbeError::misconfigured("").label(),
12565        ];
12566        let unique: HashSet<_> = labels.iter().collect();
12567        assert_eq!(unique.len(), labels.len(), "labels collided: {labels:?}");
12568    }
12569
12570    /// The class is additional information, not a replacement.
12571    ///
12572    /// An operator needs both "this was silence" and the specific text saying how
12573    /// long we waited; a classification that swallowed the message would trade one
12574    /// missing distinction for another.
12575    #[test]
12576    fn classification_preserves_the_original_message() {
12577        let err = HealthProbeError::no_answer("module did not answer within 5s");
12578        assert_eq!(err.to_string(), "module did not answer within 5s");
12579        assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
12580    }
12581}
12582
12583#[cfg(test)]
12584mod health_tombstone_tests {
12585    use std::{path::PathBuf, sync::Arc, time::Duration};
12586
12587    use subc_protocol::{
12588        manifest::Concurrency,
12589        session::{HealthStatus, ModuleControlResponse},
12590    };
12591    use tokio::sync::mpsc;
12592
12593    use super::{
12594        probe_module_health, HealthAction, HealthConfig, HealthProbeEvidence, ModuleProtocol,
12595        ModuleSpec, RestartPolicy, Supervisor, SupervisorRuntimeConfig,
12596    };
12597    use crate::{
12598        control::ControlHandler,
12599        forwarding::{ForwardingTable, ModuleControlRpcCompletion, ModuleControlRpcOutcome},
12600        registry::{ConnectionId, Registry},
12601        router::FrameSink,
12602    };
12603
12604    struct ProbeHarness {
12605        spec: ModuleSpec,
12606        runtime: SupervisorRuntimeConfig,
12607        forwarding: Arc<ForwardingTable>,
12608        module_connection: ConnectionId,
12609        module_rx: mpsc::Receiver<crate::router::OutboundFrame>,
12610        handler: ControlHandler,
12611        module: super::SupervisedModule,
12612    }
12613
12614    fn probe_harness() -> ProbeHarness {
12615        let registry = Arc::new(Registry::default());
12616        let forwarding = Arc::new(ForwardingTable::default());
12617        let supervisor_handle = super::SupervisorHandle::new();
12618        let health = HealthConfig {
12619            http: None,
12620            cadence: Duration::from_secs(30),
12621            deadline: Duration::from_secs(5),
12622            failure_threshold: 3,
12623            on_degraded: HealthAction::Report,
12624            on_failing: HealthAction::Report,
12625            critical: false,
12626        };
12627        let supervisor = Supervisor::new_for_test(Arc::clone(&registry), RestartPolicy::default())
12628            .with_forwarding(Arc::clone(&forwarding))
12629            .with_handle(supervisor_handle.clone())
12630            .with_health_config(health);
12631        let spec = ModuleSpec {
12632            module_id: "late-health-module".to_string(),
12633            program: PathBuf::from("disabled-module"),
12634            args: Vec::new(),
12635            env: Vec::new(),
12636            reserved: false,
12637            reserved_prefixes: Vec::new(),
12638            protocol: ModuleProtocol::Subc,
12639            overlap: Default::default(),
12640        };
12641        let module = supervisor
12642            .supervise_configured(spec.clone(), false)
12643            .unwrap();
12644        let runtime = supervisor.runtime_config();
12645        let handler = ControlHandler::with_forwarding(registry, Arc::clone(&forwarding))
12646            .with_supervisor(supervisor_handle);
12647        let module_connection = ConnectionId::new(700);
12648        let (module_tx, module_rx) = mpsc::channel(8);
12649        forwarding
12650            .register_module_connection(
12651                module_connection,
12652                spec.module_id.clone(),
12653                subc_protocol::PROTOCOL_VERSION,
12654                Concurrency::ModuleManaged,
12655                FrameSink::new(module_tx),
12656            )
12657            .unwrap();
12658
12659        ProbeHarness {
12660            spec,
12661            runtime,
12662            forwarding,
12663            module_connection,
12664            module_rx,
12665            handler,
12666            module,
12667        }
12668    }
12669
12670    async fn finish_after(
12671        harness: &mut ProbeHarness,
12672        stall: Duration,
12673    ) -> ModuleControlRpcCompletion {
12674        assert!(stall > harness.runtime.health.deadline);
12675        let deadline = harness.runtime.health.deadline;
12676        let probe = probe_module_health(&harness.spec.module_id, &harness.runtime, None);
12677        let answer = async {
12678            let frame = harness.module_rx.recv().await.expect("health.check frame");
12679            tokio::time::advance(deadline).await;
12680            tokio::task::yield_now().await;
12681            tokio::time::advance(stall - deadline).await;
12682            harness
12683                .forwarding
12684                .complete_module_control_rpc(
12685                    harness.module_connection,
12686                    frame.header.corr,
12687                    Some("health.check"),
12688                    ModuleControlRpcOutcome::Response(ModuleControlResponse::HealthCheck {
12689                        status: HealthStatus::Ok,
12690                        detail: None,
12691                        metrics: None,
12692                    }),
12693                )
12694                .unwrap()
12695        };
12696        let (probe_result, completion) = tokio::join!(probe, answer);
12697        let err = probe_result.expect_err("probe must miss its deadline");
12698        assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
12699        completion
12700    }
12701
12702    async fn time_out_without_answer(harness: &mut ProbeHarness) {
12703        let deadline = harness.runtime.health.deadline;
12704        let probe = probe_module_health(&harness.spec.module_id, &harness.runtime, None);
12705        let exhaust_deadline = async {
12706            let _frame = harness.module_rx.recv().await.expect("health.check frame");
12707            tokio::time::advance(deadline).await;
12708            tokio::task::yield_now().await;
12709        };
12710        let (probe_result, ()) = tokio::join!(probe, exhaust_deadline);
12711        let err = probe_result.expect_err("probe must miss its deadline");
12712        assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
12713    }
12714
12715    async fn run_probe_cycle(harness: &mut ProbeHarness, answer: bool) {
12716        let registry = Arc::clone(&harness.module.inner.registry);
12717        let snapshot = Arc::clone(&harness.module.inner.snapshot);
12718        let process_liveness = super::SupervisorProcessLiveness::default();
12719        let mut child = None;
12720        let cycle = super::run_health_probe_cycle(
12721            &harness.spec,
12722            &harness.runtime,
12723            &registry,
12724            &process_liveness,
12725            &snapshot,
12726            &mut child,
12727        );
12728        let peer = async {
12729            let frame = harness.module_rx.recv().await.expect("health.check frame");
12730            if answer {
12731                harness
12732                    .forwarding
12733                    .complete_module_control_rpc(
12734                        harness.module_connection,
12735                        frame.header.corr,
12736                        Some("health.check"),
12737                        ModuleControlRpcOutcome::Response(ModuleControlResponse::HealthCheck {
12738                            status: HealthStatus::Ok,
12739                            detail: None,
12740                            metrics: Some(serde_json::json!({"ready": true})),
12741                        }),
12742                    )
12743                    .unwrap();
12744            } else {
12745                tokio::time::advance(harness.runtime.health.deadline).await;
12746                tokio::task::yield_now().await;
12747            }
12748        };
12749        tokio::join!(cycle, peer);
12750    }
12751
12752    #[tokio::test(start_paused = true)]
12753    async fn unanswered_probe_is_unknown_until_threshold_and_ok_report_recovers() {
12754        let mut harness = probe_harness();
12755        // Drive the probe cycle directly with an in-memory wire peer. Stop the
12756        // disabled module's monitor so only this test owns lifecycle transitions;
12757        // no OS process is launched, and a restart is observed at scheduling.
12758        let monitor = harness.module.inner.monitor.lock().unwrap().take().unwrap();
12759        monitor.abort();
12760        let _ = monitor.await;
12761        super::update_snapshot(&harness.module.inner.snapshot, None, |state| {
12762            state.enabled = true;
12763            state.state = super::ModuleState::Running;
12764            state.process_alive = true;
12765        })
12766        .unwrap();
12767
12768        run_probe_cycle(&mut harness, true).await;
12769        assert_eq!(
12770            harness.module.status().unwrap().health.status,
12771            super::SupervisorHealthStatus::Ok
12772        );
12773
12774        for failures in 1..harness.runtime.health.failure_threshold {
12775            run_probe_cycle(&mut harness, false).await;
12776            let status = harness.module.status().unwrap();
12777            assert_eq!(status.health.status, super::SupervisorHealthStatus::Unknown);
12778            assert_eq!(status.health.consecutive_failures, failures);
12779            assert!(status.health.last_probe_ms.is_some());
12780            assert!(status.health.detail.unwrap().starts_with("[no-answer]"));
12781            assert!(status.health.metrics.is_none());
12782            assert_eq!(status.state, super::ModuleState::Running);
12783            assert!(status.process_alive);
12784            assert_eq!(status.restart_count, 0);
12785            assert_eq!(status.lifetime_restarts, 0);
12786            assert!(status.health.last_action.is_none());
12787            assert!(harness.runtime.scheduled_respawn.lock().unwrap().is_none());
12788        }
12789
12790        run_probe_cycle(&mut harness, true).await;
12791        let recovered = harness.module.status().unwrap();
12792        assert_eq!(recovered.health.status, super::SupervisorHealthStatus::Ok);
12793        assert_eq!(recovered.health.consecutive_failures, 0);
12794        assert!(recovered.health.detail.is_none());
12795        assert_eq!(
12796            recovered.health.metrics,
12797            Some(serde_json::json!({"ready": true}))
12798        );
12799        assert_eq!(recovered.lifetime_restarts, 0);
12800
12801        for failures in 1..=harness.runtime.health.failure_threshold {
12802            run_probe_cycle(&mut harness, false).await;
12803            let status = harness.module.status().unwrap();
12804            assert_eq!(status.health.consecutive_failures, failures);
12805            if failures < harness.runtime.health.failure_threshold {
12806                assert_eq!(status.health.status, super::SupervisorHealthStatus::Unknown);
12807                assert_eq!(status.state, super::ModuleState::Running);
12808                assert_eq!(status.lifetime_restarts, 0);
12809                assert!(harness.runtime.scheduled_respawn.lock().unwrap().is_none());
12810            } else {
12811                assert_eq!(
12812                    status.health.status,
12813                    super::SupervisorHealthStatus::Unresponsive
12814                );
12815                assert_eq!(status.state, super::ModuleState::Restarting);
12816                assert_eq!(status.restart_count, 1);
12817                assert_eq!(status.lifetime_restarts, 1);
12818                assert_eq!(status.health.last_action.as_deref(), Some("restart"));
12819                assert!(harness.runtime.scheduled_respawn.lock().unwrap().is_some());
12820            }
12821        }
12822    }
12823
12824    #[tokio::test(start_paused = true)]
12825    async fn late_health_answers_record_start_anchored_latency_for_two_stalls() {
12826        let mut harness = probe_harness();
12827
12828        let first = finish_after(&mut harness, Duration::from_secs(8)).await;
12829        let first_latency = match &first {
12830            ModuleControlRpcCompletion::LateHealthAnswer { latency, .. } => *latency,
12831            other => panic!("late answer was not retained: {other:?}"),
12832        };
12833        assert!(harness.handler.observe_module_control_completion(first));
12834
12835        let second = finish_after(&mut harness, Duration::from_secs(11)).await;
12836        let second_latency = match &second {
12837            ModuleControlRpcCompletion::LateHealthAnswer { latency, .. } => *latency,
12838            other => panic!("late answer was not retained: {other:?}"),
12839        };
12840        assert!(harness.handler.observe_module_control_completion(second));
12841
12842        assert_eq!(first_latency, Duration::from_secs(8));
12843        assert_eq!(
12844            second_latency - first_latency,
12845            Duration::from_secs(3),
12846            "latency must grow linearly with the additional stall"
12847        );
12848        let health = harness.module.status().unwrap().health;
12849        assert_eq!(health.late_answer_count, 2);
12850        assert_eq!(health.last_late_answer_latency_ms, Some(11_000));
12851    }
12852
12853    /// A module that answers every probe late must never march to the kill
12854    /// threshold: the late answer proves it is alive, so it must clear the miss
12855    /// streak the timeout recorded. Without the reset, a CPU-starved module
12856    /// that serves every probe seconds past the deadline accumulates
12857    /// `consecutive_failures` to the threshold and is killed — the exact
12858    /// sequence from the 2026-08-14 aft disable, where the daemon logged
12859    /// "proves the module is alive" five times while counting five misses.
12860    #[tokio::test(start_paused = true)]
12861    async fn late_answer_clears_the_consecutive_failure_streak() {
12862        let mut harness = probe_harness();
12863
12864        // Timeout recorded first: the probe path saw no answer in time.
12865        time_out_without_answer(&mut harness).await;
12866        harness
12867            .module
12868            .record_health_probe_failure_for_test("[no-answer] test miss")
12869            .unwrap();
12870        assert_eq!(
12871            harness.module.status().unwrap().health.consecutive_failures,
12872            1,
12873            "precondition: the miss must be on the streak before the late answer"
12874        );
12875
12876        // The stalled reply then lands: proof of life.
12877        let late = finish_after(&mut harness, Duration::from_secs(9)).await;
12878        assert!(matches!(
12879            late,
12880            ModuleControlRpcCompletion::LateHealthAnswer { .. }
12881        ));
12882        assert!(harness.handler.observe_module_control_completion(late));
12883
12884        let health = harness.module.status().unwrap().health;
12885        assert_eq!(
12886            health.consecutive_failures, 0,
12887            "a late answer is an answer: the streak must reset"
12888        );
12889        assert_eq!(health.late_answer_count, 1);
12890    }
12891
12892    #[tokio::test(start_paused = true)]
12893    async fn repeated_serial_probe_cycles_keep_one_tombstone_per_endpoint() {
12894        let mut harness = probe_harness();
12895
12896        for _ in 0..20 {
12897            time_out_without_answer(&mut harness).await;
12898            assert_eq!(
12899                harness.forwarding.health_probe_tombstone_count().unwrap(),
12900                1
12901            );
12902        }
12903    }
12904}
12905
12906#[cfg(test)]
12907mod child_env_tests {
12908    #[cfg(windows)]
12909    use super::{
12910        clear_current_process_facts, observed_launch_nonce_source, ModuleState, SupervisorSnapshot,
12911    };
12912    #[cfg(windows)]
12913    use std::sync::{Arc, OnceLock};
12914    #[cfg(windows)]
12915    #[test]
12916    fn windows_wire_spawn_prepares_pipe_and_keeps_phase_one_env() {
12917        let spec = spec(Vec::new());
12918        let mut command = Command::new("unused");
12919        let handoff = apply_wire_spawn_args(&mut command, &spec, None, None)
12920            .unwrap()
12921            .unwrap();
12922        let name = command
12923            .as_std()
12924            .get_envs()
12925            .find(|(key, _)| *key == OsStr::new(subc_os::launch_nonce::LAUNCH_NONCE_PIPE_ENV))
12926            .and_then(|(_, value)| value)
12927            .unwrap();
12928        assert_eq!(name, OsStr::new(handoff.name()));
12929        assert!(handoff.name().starts_with(r"\\.\pipe\subc-launch-"));
12930        assert!(command.as_std().get_envs().any(|(key, value)| key
12931            == OsStr::new(subc_os::LAUNCH_NONCE_PIPE_FALLBACK_ENV)
12932            && value == Some(OsStr::new("env"))));
12933        assert!(command
12934            .as_std()
12935            .get_envs()
12936            .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some()));
12937    }
12938
12939    #[cfg(windows)]
12940    #[test]
12941    fn windows_source_reporting_tracks_consumption_and_resets_per_spawn() {
12942        let mut snapshot = SupervisorSnapshot::new(ModuleState::Running, true);
12943        snapshot.process_alive = true;
12944        snapshot.spawned_protocol = Some(ModuleProtocol::Subc);
12945        assert_eq!(
12946            observed_launch_nonce_source(&snapshot).as_deref(),
12947            Some("env")
12948        );
12949        let consumed = Arc::new(OnceLock::new());
12950        snapshot.pipe_consumed = Some(consumed.clone());
12951        assert_eq!(
12952            observed_launch_nonce_source(&snapshot).as_deref(),
12953            Some("env")
12954        );
12955        consumed.set(()).unwrap();
12956        assert_eq!(
12957            observed_launch_nonce_source(&snapshot).as_deref(),
12958            Some("pipe")
12959        );
12960        snapshot.pipe_consumed = Some(Arc::new(OnceLock::new()));
12961        assert_eq!(
12962            observed_launch_nonce_source(&snapshot).as_deref(),
12963            Some("env")
12964        );
12965        clear_current_process_facts(&mut snapshot);
12966        assert_eq!(observed_launch_nonce_source(&snapshot), None);
12967    }
12968    use super::{
12969        apply_child_env, apply_spawn_role, apply_wire_spawn_args, ModuleProtocol, ModuleSpec,
12970        SpawnRole, SupervisorHandle, SPAWN_ROLE_SWAP_CANDIDATE, SUBC_ARG, SUBC_LAUNCH_NONCE_ENV,
12971        SUBC_MODULE_ID_ENV, SUBC_SPAWN_ROLE_ENV,
12972    };
12973    use std::{ffi::OsStr, path::PathBuf};
12974    use tokio::process::Command;
12975
12976    fn spec(env: Vec<(String, String)>) -> ModuleSpec {
12977        ModuleSpec {
12978            module_id: "env-plan".to_string(),
12979            program: PathBuf::from("/nonexistent"),
12980            args: Vec::new(),
12981            env,
12982            reserved: false,
12983            reserved_prefixes: Vec::new(),
12984            protocol: ModuleProtocol::Subc,
12985            overlap: Default::default(),
12986        }
12987    }
12988
12989    /// Ambient `CK_LOG` is REMOVED for an unconfigured module, and a configured
12990    /// one still gets its own.
12991    ///
12992    /// This is the narrow goal `env_clear()` was reached for, and the reason the
12993    /// fix is `env_remove` rather than deleting the line: an operator's ambient
12994    /// filter silently becoming an unconfigured module's log level is a real
12995    /// defect, just a much smaller one than clearing the environment.
12996    ///
12997    /// Asserted on the command plan rather than a spawned child because proving
12998    /// the ABSENCE of an inherited variable needs the parent's environment
12999    /// mutated, and `forbid(unsafe_code)` refuses that. `get_envs()` reports a
13000    /// removal as `(key, None)`, which is exactly the distinction wanted: not
13001    /// "absent because nobody set it" but "explicitly unset for the child".
13002    #[test]
13003    fn ambient_ck_log_is_removed_and_a_configured_one_survives() {
13004        let mut command = Command::new("/nonexistent");
13005        apply_child_env(&mut command, &spec(Vec::new()));
13006        let removed = command
13007            .as_std()
13008            .get_envs()
13009            .any(|(key, value)| key == OsStr::new("CK_LOG") && value.is_none());
13010        assert!(
13011            removed,
13012            "ambient CK_LOG must be explicitly removed for an unconfigured module"
13013        );
13014
13015        let mut configured = Command::new("/nonexistent");
13016        apply_child_env(
13017            &mut configured,
13018            &spec(vec![("CK_LOG".to_string(), "debug".to_string())]),
13019        );
13020        let effective = configured
13021            .as_std()
13022            .get_envs()
13023            .filter(|(key, _)| *key == OsStr::new("CK_LOG"))
13024            .last()
13025            .map(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()));
13026        assert_eq!(
13027            effective,
13028            Some(Some("debug".to_string())),
13029            "a module's configured CK_LOG must survive the ambient removal"
13030        );
13031    }
13032
13033    /// A `protocol: "none"` spawn carries NO `--subc` argument and NO launch
13034    /// nonce; a subc-wire spawn carries both. Asserted on the command plan for
13035    /// the same reason as the CK_LOG test above.
13036    ///
13037    /// The argument is the load-bearing half: a stock binary exits on an
13038    /// unknown flag before it listens, so with `--subc` appended the mode
13039    /// could not supervise the one process it exists for. Found by the first
13040    /// conformance run (nats-server: `flag provided but not defined: -subc`).
13041    #[test]
13042    fn protocol_none_spawn_carries_no_subc_argument_and_no_nonce() {
13043        let connection_file = std::path::Path::new("/run/subc-connection.json");
13044        let handle = SupervisorHandle::new();
13045
13046        let mut none_spec = spec(Vec::new());
13047        none_spec.protocol = ModuleProtocol::None;
13048        let mut none = Command::new("/nonexistent");
13049        let none_handoff =
13050            apply_wire_spawn_args(&mut none, &none_spec, Some(connection_file), Some(&handle))
13051                .expect("protocol-none spawn args apply");
13052        assert!(
13053            none_handoff.is_none(),
13054            "protocol:none spawn must not receive a nonce descriptor"
13055        );
13056        #[cfg(windows)]
13057        assert!(!none.as_std().get_envs().any(|(key, value)| key
13058            == OsStr::new(subc_os::LAUNCH_NONCE_PIPE_FALLBACK_ENV)
13059            && value.is_some()));
13060        assert!(
13061            !none.as_std().get_envs().any(|(key, value)| key
13062                == OsStr::new(subc_os::LAUNCH_NONCE_FD_ENV)
13063                && value.is_some()),
13064            "protocol:none spawn must not name a nonce descriptor"
13065        );
13066        let none_args: Vec<String> = none
13067            .as_std()
13068            .get_args()
13069            .map(|a| a.to_string_lossy().into_owned())
13070            .collect();
13071        assert!(
13072            !none_args.iter().any(|a| a == SUBC_ARG),
13073            "protocol:none argv must not carry --subc; got {none_args:?}"
13074        );
13075        let none_has_nonce = none
13076            .as_std()
13077            .get_envs()
13078            .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some());
13079        assert!(
13080            !none_has_nonce,
13081            "protocol:none spawn must not receive a launch nonce"
13082        );
13083        let none_has_module_id = none
13084            .as_std()
13085            .get_envs()
13086            .any(|(key, value)| key == OsStr::new(SUBC_MODULE_ID_ENV) && value.is_some());
13087        assert!(
13088            none_has_module_id,
13089            "SUBC_MODULE_ID is inert and stays on every path"
13090        );
13091        assert!(
13092            handle.spawn_nonce(&none_spec.module_id).is_none(),
13093            "no nonce record for a process that will never present one"
13094        );
13095
13096        // Control: the subc-wire path is unchanged by the branch above.
13097        let wire_spec = spec(Vec::new());
13098        let mut wire = Command::new("/nonexistent");
13099        let wire_handoff =
13100            apply_wire_spawn_args(&mut wire, &wire_spec, Some(connection_file), Some(&handle))
13101                .expect("subc-wire spawn args apply");
13102        let wire_fd_env = wire
13103            .as_std()
13104            .get_envs()
13105            .find(|(key, _)| *key == OsStr::new(subc_os::LAUNCH_NONCE_FD_ENV))
13106            .and_then(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()));
13107        #[cfg(unix)]
13108        assert_eq!(
13109            wire_fd_env,
13110            Some(wire_handoff.expect("a descriptor handoff").fd_env_value()),
13111            "a subc-wire spawn names the pipe it will receive at descriptor 3"
13112        );
13113        #[cfg(windows)]
13114        assert!(wire_handoff.is_some() && wire_fd_env.is_none());
13115        #[cfg(not(any(unix, windows)))]
13116        assert!(wire_handoff.is_none() && wire_fd_env.is_none());
13117        let wire_args: Vec<String> = wire
13118            .as_std()
13119            .get_args()
13120            .map(|a| a.to_string_lossy().into_owned())
13121            .collect();
13122        assert_eq!(
13123            wire_args,
13124            vec![
13125                SUBC_ARG.to_string(),
13126                connection_file.to_string_lossy().into_owned()
13127            ],
13128            "a subc-wire spawn still carries --subc <path>"
13129        );
13130        assert_eq!(
13131            wire.as_std()
13132                .get_envs()
13133                .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some()),
13134            !cfg!(unix),
13135            "only Windows supplies the environment nonce"
13136        );
13137        assert!(handle.spawn_nonce(&wire_spec.module_id).is_some());
13138    }
13139
13140    /// A plain spawn EXPLICITLY REMOVES the spawn role, even when the module's
13141    /// spec tries to set it; only a swap candidate carries it.
13142    ///
13143    /// "Set it only on candidates" is not enough, because spawn applies the
13144    /// spec's env verbatim and the daemon's own environment is inherited: either
13145    /// could hand a plain restart the swap role, and a module reading it would
13146    /// warm on its long swap budget while callers wait. Asserted as an explicit
13147    /// removal (`(key, None)`), not mere absence, for the reason the `CK_LOG`
13148    /// test above gives.
13149    #[test]
13150    fn plain_spawn_removes_the_spawn_role_even_when_the_spec_sets_it() {
13151        let role = |command: &Command| {
13152            command
13153                .as_std()
13154                .get_envs()
13155                .filter(|(key, _)| *key == OsStr::new(SUBC_SPAWN_ROLE_ENV))
13156                .last()
13157                .map(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()))
13158        };
13159        let forged = spec(vec![(
13160            SUBC_SPAWN_ROLE_ENV.to_string(),
13161            SPAWN_ROLE_SWAP_CANDIDATE.to_string(),
13162        )]);
13163
13164        let mut plain = Command::new("/nonexistent");
13165        apply_child_env(&mut plain, &forged);
13166        apply_spawn_role(&mut plain, SpawnRole::Plain);
13167        assert_eq!(
13168            role(&plain),
13169            Some(None),
13170            "a plain spawn must remove SUBC_SPAWN_ROLE, whatever the spec says"
13171        );
13172
13173        let mut candidate = Command::new("/nonexistent");
13174        apply_child_env(&mut candidate, &spec(Vec::new()));
13175        apply_spawn_role(&mut candidate, SpawnRole::SwapCandidate);
13176        assert_eq!(
13177            role(&candidate),
13178            Some(Some(SPAWN_ROLE_SWAP_CANDIDATE.to_string()))
13179        );
13180    }
13181
13182    /// Daemon-private capture retention keys never reach the child.
13183    ///
13184    /// cortexkit-log exposes retention as a Rust struct with no environment
13185    /// names, so these entries are supervisor metadata. Passing them through
13186    /// would invent a public child-process contract by accident.
13187    #[test]
13188    fn daemon_private_capture_keys_are_not_passed_to_the_child() {
13189        let mut command = Command::new("/nonexistent");
13190        apply_child_env(
13191            &mut command,
13192            &spec(vec![
13193                (super::CAPTURE_KEEP_ENV.to_string(), "5".to_string()),
13194                ("KEPT".to_string(), "yes".to_string()),
13195            ]),
13196        );
13197        let keys: Vec<String> = command
13198            .as_std()
13199            .get_envs()
13200            .filter(|(_, value)| value.is_some())
13201            .map(|(key, _)| key.to_string_lossy().into_owned())
13202            .collect();
13203        assert!(keys.contains(&"KEPT".to_string()), "got {keys:?}");
13204        assert!(
13205            !keys.contains(&super::CAPTURE_KEEP_ENV.to_string()),
13206            "daemon-private capture key leaked to the child: {keys:?}"
13207        );
13208    }
13209}
13210
13211#[cfg(test)]
13212mod jitter_tests {
13213    use super::jittered_health_delay;
13214    use std::{collections::HashSet, time::Duration};
13215
13216    /// Module ids drawn from a real fleet, so the dispersal claim is about names
13217    /// that actually occur rather than invented ones.
13218    ///
13219    /// This is a SAMPLE, not a registry: the property under test is that distinct
13220    /// ids disperse, which holds for any set of distinct strings. Several entries
13221    /// are already historical (modules get renamed), and that costs nothing here --
13222    /// but it means a reader must not mistake this for the live module set, and a
13223    /// rename sweep will match it without there being anything to change.
13224    const FLEET: [&str; 14] = [
13225        "aft",
13226        "alfonso-core",
13227        "magic-context",
13228        "broca",
13229        "thalamus",
13230        "quota",
13231        "engram",
13232        "plexus",
13233        "cerebellum",
13234        "astrocyte",
13235        "synapse",
13236        "subc-mcp",
13237        "cortexkit-credentials",
13238        "subc-federation",
13239    ];
13240
13241    /// Probes must not converge after a fleet-wide restart.
13242    ///
13243    /// This is the property the jitter exists for: every module reconnects at
13244    /// once, and without dispersal all fourteen would then probe on the same
13245    /// tick forever. Nothing failed visibly when this went untested -- a
13246    /// convergent fleet still probes correctly, just in a burst, so the symptom
13247    /// is a periodic load spike that looks like whatever else is running.
13248    #[test]
13249    fn probe_delays_disperse_across_the_fleet() {
13250        let cadence = Duration::from_secs(30);
13251        let delays: HashSet<Duration> = FLEET
13252            .iter()
13253            .map(|id| jittered_health_delay(id, 0, cadence))
13254            .collect();
13255        assert_eq!(
13256            delays.len(),
13257            FLEET.len(),
13258            "every supervised module must land on its own probe offset"
13259        );
13260    }
13261
13262    /// The offset may only ever DELAY a probe, never bring it forward.
13263    ///
13264    /// A delay below the cadence would probe a module more often than
13265    /// configured, which is the opposite of what an operator asked for and
13266    /// would tighten the failure budget without anyone changing it.
13267    #[test]
13268    fn jitter_only_delays_and_stays_within_one_tenth_of_cadence() {
13269        let cadence = Duration::from_secs(30);
13270        let span = cadence / 10;
13271        for id in FLEET {
13272            for probe_index in 0..8 {
13273                let delay = jittered_health_delay(id, probe_index, cadence);
13274                assert!(
13275                    delay >= cadence,
13276                    "{id}#{probe_index}: jitter must not shorten the cadence"
13277                );
13278                assert!(
13279                    delay < cadence + span,
13280                    "{id}#{probe_index}: jitter must stay inside one tenth of the cadence"
13281                );
13282            }
13283        }
13284    }
13285
13286    /// A module keeps its offset across daemon restarts.
13287    ///
13288    /// The delay is derived rather than randomised precisely so a restart does
13289    /// not re-roll every module into a fresh chance of collision. A random
13290    /// source would satisfy the dispersal test above and quietly lose this.
13291    #[test]
13292    fn a_module_offset_is_stable_across_restarts() {
13293        let cadence = Duration::from_secs(30);
13294        for id in FLEET {
13295            assert_eq!(
13296                jittered_health_delay(id, 0, cadence),
13297                jittered_health_delay(id, 0, cadence),
13298                "{id}: the same module and probe index must produce the same offset"
13299            );
13300        }
13301    }
13302
13303    /// A zero cadence disables probing rather than producing a busy loop.
13304    #[test]
13305    fn zero_cadence_yields_zero_delay() {
13306        assert_eq!(
13307            jittered_health_delay("aft", 0, Duration::ZERO),
13308            Duration::ZERO
13309        );
13310    }
13311}
13312
13313#[cfg(all(test, target_os = "linux"))]
13314mod cgroup_placement_tests {
13315    use super::{
13316        apply_cgroup_placement, remove_module_cgroup, ModuleProtocol, ModuleSpec, SuperviseError,
13317        SupervisedChild,
13318    };
13319    use crate::stderr_tail::{StderrRing, StderrTailConfig};
13320    use cortexkit_test_support::ScratchDir;
13321    use std::{
13322        fs, io,
13323        path::{Path, PathBuf},
13324        sync::{Arc, Mutex},
13325    };
13326    use tokio::process::Command;
13327
13328    #[tokio::test]
13329    async fn unique_spawn_cgroups_clean_up_across_restarts_and_shutdown() {
13330        use super::*;
13331        let dir = ScratchDir::new("unique-spawn-cgroups");
13332        let root = PathBuf::from(format!(
13333            "/sys/fs/cgroup/subc-unique-test-{}-{}",
13334            std::process::id(),
13335            unix_ms_now()
13336        ));
13337        if let Err(error) = fs::create_dir(&root) {
13338            assert!(
13339                std::env::var_os("SUBC_REQUIRE_CGROUP_TEST").is_none(),
13340                "required cgroup test cannot execute: {error}"
13341            );
13342            eprintln!("SKIP unique_spawn_cgroups_clean_up_across_restarts_and_shutdown: {error}");
13343            return;
13344        }
13345        let placement = subc_cgroup::prepare_at(&root).unwrap().unwrap();
13346        let group_count = || {
13347            fs::read_dir(root.join("subc-modules"))
13348                .unwrap()
13349                .map(|entry| entry.unwrap().file_type().unwrap())
13350                .filter(|kind| kind.is_dir())
13351                .count()
13352        };
13353        let supervisor =
13354            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
13355                .with_cgroup_placement(Some(placement.clone()));
13356        let runtime = supervisor.runtime_config();
13357        let mut spec = ModuleSpec {
13358            module_id: "unique-spawn".into(),
13359            program: PathBuf::from("/bin/sleep"),
13360            args: vec!["60".into()],
13361            env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
13362                .into_iter()
13363                .map(|key| (key.into(), dir.to_string_lossy().into_owned()))
13364                .collect(),
13365            reserved: false,
13366            reserved_prefixes: vec![],
13367            protocol: ModuleProtocol::None,
13368            overlap: Default::default(),
13369        };
13370        let spawn = |spec: &ModuleSpec| {
13371            spawn_child(
13372                spec,
13373                None,
13374                None,
13375                &runtime.stderr_ring,
13376                None,
13377                &runtime.child_roster,
13378                Some(&placement),
13379            )
13380            .unwrap()
13381        };
13382        let mut live = spawn(&spec);
13383        for _ in 0..3 {
13384            // A new process can enter the old slot while retirement is pending.
13385            let next = spawn(&spec);
13386            assert_ne!(live.module_id, next.module_id);
13387            live.start_kill().unwrap();
13388            live.wait().await.unwrap();
13389            live = next;
13390            assert!(
13391                live.child.try_wait().unwrap().is_none(),
13392                "retiring the old slot must not kill the replacement"
13393            );
13394            assert_eq!(
13395                group_count(),
13396                1,
13397                "only the live spawn's cgroup should remain"
13398            );
13399        }
13400        supervisor.begin_daemon_shutdown();
13401        let reap = tokio::spawn(async move {
13402            live.wait().await.unwrap();
13403        });
13404        supervisor
13405            .end_children_for_daemon_shutdown(false, std::future::pending())
13406            .await;
13407        reap.await.unwrap();
13408        assert_eq!(group_count(), 0);
13409        // A normal exit uses the same tree-cleanup path as a killed spawn.
13410        spec.program = PathBuf::from("/bin/true");
13411        spec.args.clear();
13412        let fresh_roster = ChildRoster::default();
13413        let mut short = spawn_child(
13414            &spec,
13415            None,
13416            None,
13417            &runtime.stderr_ring,
13418            None,
13419            &fresh_roster,
13420            Some(&placement),
13421        )
13422        .unwrap();
13423        short.wait().await.unwrap();
13424        assert_eq!(group_count(), 0);
13425        spec.module_id = "_".repeat(255);
13426        let mut long_id = spawn_child(
13427            &spec,
13428            None,
13429            None,
13430            &runtime.stderr_ring,
13431            None,
13432            &fresh_roster,
13433            Some(&placement),
13434        )
13435        .unwrap();
13436        long_id.wait().await.unwrap();
13437        assert_eq!(
13438            group_count(),
13439            0,
13440            "valid long module IDs must not exceed cgroup NAME_MAX"
13441        );
13442        fs::remove_dir(root.join("subc-modules")).unwrap();
13443        fs::remove_dir(root).unwrap();
13444        eprintln!("EXECUTED unique_spawn_cgroups_clean_up_across_restarts_and_shutdown");
13445    }
13446
13447    #[test]
13448    fn failed_parent_cgroup_open_is_a_cgroup_supervision_error() {
13449        let path = Path::new("/definitely-missing-subc-cgroup");
13450        let mut command = Command::new("true");
13451        let error = apply_cgroup_placement(
13452            &mut command,
13453            &ModuleSpec {
13454                module_id: "broken-cgroup".to_string(),
13455                program: PathBuf::from("true"),
13456                args: Vec::new(),
13457                env: Vec::new(),
13458                reserved: false,
13459                reserved_prefixes: Vec::new(),
13460                protocol: ModuleProtocol::Subc,
13461                overlap: Default::default(),
13462            },
13463            path,
13464        )
13465        .expect_err("a parent cgroup open failure must reject the supervised spawn");
13466        let reason = error.to_string();
13467
13468        assert!(
13469            matches!(error, SuperviseError::Cgroup { .. }),
13470            "parent cgroup open must be reported as a cgroup supervision error: {reason}"
13471        );
13472        assert!(
13473            reason.contains("/definitely-missing-subc-cgroup/cgroup.procs"),
13474            "parent cgroup open failure must name cgroup.procs: {reason}"
13475        );
13476    }
13477
13478    #[tokio::test]
13479    async fn reaping_a_child_removes_its_empty_module_cgroup() {
13480        let root = ScratchDir::new("supervisor-reap-cgroup");
13481        fs::write(root.join("cgroup.procs"), b"").expect("write scratch cgroup marker");
13482        let placement = subc_cgroup::prepare_at(&root)
13483            .expect("prepare scratch cgroup root")
13484            .expect("scratch root has a cgroup.procs marker");
13485        let module_id = "reaped-module";
13486        let module = placement
13487            .module_path(module_id)
13488            .expect("create scratch module cgroup");
13489        let child = Command::new("true")
13490            .env("XDG_DATA_HOME", root.path())
13491            .env("XDG_RUNTIME_DIR", root.path())
13492            .env("XDG_CONFIG_HOME", root.path())
13493            .spawn()
13494            .expect("spawn short-lived child");
13495        let pid = child.id().expect("spawned child has pid");
13496        let mut child = SupervisedChild {
13497            child,
13498            protocol: ModuleProtocol::Subc,
13499            module_id: module_id.to_string(),
13500            cgroup_placement: Some(placement),
13501            stdout_pump: None,
13502            stderr_pump: None,
13503            stderr_ring: Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default()))),
13504            spawned_at_ms: 0,
13505            spawned_from: PathBuf::from("true"),
13506            spawned_file_identity: None,
13507            process_start_time: None,
13508            process_identity: None,
13509            pid,
13510            roster_guard: None,
13511            #[cfg(target_os = "macos")]
13512            privacy_exec: None,
13513            spawn_failure: None,
13514        };
13515
13516        child.wait().await.expect("reap short-lived child");
13517
13518        assert!(
13519            !module.exists(),
13520            "reaping the supervised child must remove its empty cgroup"
13521        );
13522    }
13523
13524    #[test]
13525    fn non_empty_cgroup_removal_is_reported_without_blocking_teardown() {
13526        let root = ScratchDir::new("supervisor-non-empty-cgroup");
13527        fs::write(root.join("cgroup.procs"), b"").expect("write scratch cgroup marker");
13528        let placement = subc_cgroup::prepare_at(&root)
13529            .expect("prepare scratch cgroup root")
13530            .expect("scratch root has a cgroup.procs marker");
13531        let module = placement
13532            .module_path("surviving-module")
13533            .expect("create scratch module cgroup");
13534        fs::write(module.join("surviving-process"), b"still present")
13535            .expect("make scratch cgroup non-empty");
13536        let (logs, _guard) = crate::router::test_log::log_capture(tracing::Level::WARN);
13537
13538        remove_module_cgroup(&placement, "surviving-module");
13539
13540        let logs = crate::router::test_log::captured_logs(&logs);
13541        assert!(
13542            module.exists(),
13543            "failed removal must leave the cgroup intact"
13544        );
13545        assert!(
13546            logs.contains("could not remove module cgroup after process exit; continuing teardown")
13547                && logs.contains("surviving-module"),
13548            "best-effort removal must report the failure without returning it: {logs}"
13549        );
13550    }
13551
13552    #[test]
13553    fn cgroup_pre_exec_spawn_failure_names_the_cgroup_path() {
13554        let cgroup_path = PathBuf::from("/sys/fs/cgroup/subc-modules/broken-module");
13555        let reason = SuperviseError::Spawn {
13556            program: PathBuf::from("/bin/true"),
13557            source: io::Error::from_raw_os_error(13),
13558            cgroup_path: Some(cgroup_path.clone()),
13559        }
13560        .to_string();
13561
13562        assert!(
13563            reason.contains(&cgroup_path.display().to_string()),
13564            "a pre_exec spawn failure must name the cgroup path: {reason}"
13565        );
13566    }
13567}
13568
13569#[cfg(test)]
13570mod spawn_subscriber_lag_tests {
13571    use super::*;
13572
13573    /// A subscriber whose connection stops draining is dropped once its frame
13574    /// channel fills. The client must learn that from a terminal Error frame
13575    /// after the frames already queued for it, not from a stream that simply
13576    /// goes quiet.
13577    #[tokio::test]
13578    async fn lagged_spawn_subscriber_receives_a_terminal_lagged_error_after_its_queued_frames() {
13579        let feed = SpawnEventFeed::default();
13580        feed.configure_incarnation("lag-incarnation".to_string());
13581        // A one-slot connection queue that nobody reads until the emits are
13582        // done: the forwarder parks on it and the subscriber channel fills.
13583        let (tx, mut rx) = mpsc::channel(1);
13584        feed.subscribe(ConnectionId::new(1), 7, 1, None, FrameSink::new(tx))
13585            .expect("subscribe");
13586        let emitted = SPAWN_SUBSCRIBER_BUFFER + 16;
13587        for index in 0..emitted {
13588            feed.emit_spawned(&format!("lag-module-{index}"), 1000, 0);
13589            // Let the forwarder take what it can so the fill point is the
13590            // subscriber channel, not a scheduling accident.
13591            tokio::task::yield_now().await;
13592        }
13593        assert_eq!(
13594            feed.subscriber_count(),
13595            0,
13596            "the lagged subscriber must be removed"
13597        );
13598
13599        let mut data = Vec::new();
13600        let mut last = None;
13601        loop {
13602            let next = tokio::time::timeout(Duration::from_secs(5), rx.recv())
13603                .await
13604                .expect("the forwarder must finish once the subscriber is dropped");
13605            let Some(outbound) = next else { break };
13606            let frame = outbound.frame;
13607            if frame.header.ty == FrameType::StreamData {
13608                assert!(last.is_none(), "no data may follow the terminal frame");
13609                let event: SpawnEvent = serde_json::from_slice(&frame.body).unwrap();
13610                data.push(event.cursor.seq);
13611            } else {
13612                assert!(last.is_none(), "exactly one terminal frame");
13613                last = Some(frame);
13614            }
13615        }
13616        assert!(!data.is_empty(), "queued frames drain before the terminal");
13617        for pair in data.windows(2) {
13618            assert_eq!(
13619                pair[1],
13620                pair[0] + 1,
13621                "queued frames arrive dense and in order"
13622            );
13623        }
13624        let terminal = last.expect("a lagged subscriber must receive a terminal frame");
13625        assert_eq!(terminal.header.ty, FrameType::Error);
13626        assert_eq!(terminal.header.corr, 7);
13627        let body: subc_protocol::ErrorBody = serde_json::from_slice(&terminal.body).unwrap();
13628        assert_eq!(body.code, SPAWN_SUBSCRIBER_LAGGED_CODE);
13629        let detail = body.detail.expect("lagged error carries detail");
13630        assert_eq!(
13631            detail["first_undelivered_cursor"]["seq"],
13632            data.last().unwrap() + 1,
13633            "the named cursor is the first event the subscriber did not receive"
13634        );
13635        assert_eq!(
13636            detail["first_undelivered_cursor"]["daemon_incarnation"],
13637            "lag-incarnation"
13638        );
13639    }
13640}
13641
13642#[cfg(test)]
13643mod terminal_history_read_concurrency_tests {
13644    use super::*;
13645    use crate::terminal_journal::read_pause;
13646    use cortexkit_test_support::ScratchDir;
13647    use std::sync::mpsc as std_mpsc;
13648
13649    fn journaled_ring(
13650        journal: &Arc<crate::terminal_journal::TerminalJournal>,
13651    ) -> Arc<Mutex<TerminalRing>> {
13652        Arc::new(Mutex::new(
13653            TerminalRing::new(TerminalRingConfig::default(), 1)
13654                .with_journal(Some(Arc::clone(journal))),
13655        ))
13656    }
13657
13658    fn crash(at_ms: u64) -> ExitReport {
13659        ExitReport {
13660            kind: ExitKind::Crash,
13661            code: Some(1),
13662            signal: None,
13663            at_ms,
13664        }
13665    }
13666
13667    /// Record an exit on another thread and report whether it finished within
13668    /// `bound`. The recorder thread is left running if it did not.
13669    fn record_within(
13670        module_id: &'static str,
13671        ring: &Arc<Mutex<TerminalRing>>,
13672        at_ms: u64,
13673        bound: Duration,
13674    ) -> bool {
13675        let ring = Arc::clone(ring);
13676        let (done, done_rx) = std_mpsc::channel();
13677        std::thread::spawn(move || {
13678            record_terminal(
13679                module_id,
13680                &ring,
13681                &SpawnEventFeed::default(),
13682                &crash(at_ms),
13683                TerminalDisposition::Restarting,
13684            );
13685            let _ = done.send(());
13686        });
13687        done_rx.recv_timeout(bound).is_ok()
13688    }
13689
13690    /// A history read in progress must not hold the journal writer (which every
13691    /// module's exit recording needs) or the module's own ring. Exits recorded
13692    /// while the read is paused complete promptly; the paused read answers as of
13693    /// the moment it started, and the next read has each exit exactly once.
13694    #[test]
13695    fn exits_recorded_during_a_paused_history_read_are_not_blocked_or_half_merged() {
13696        let dir = ScratchDir::new("terminal-history-concurrent-read");
13697        let path = dir.join("terminals.jsonl");
13698        let journal = Arc::new(crate::terminal_journal::TerminalJournal::open(
13699            path.clone(),
13700            "daemon".into(),
13701        ));
13702        let reader_ring = journaled_ring(&journal);
13703        let other_ring = journaled_ring(&journal);
13704        assert!(record_within(
13705            "reader-module",
13706            &reader_ring,
13707            10,
13708            Duration::from_secs(5)
13709        ));
13710
13711        let (started, release) = read_pause::install(&path);
13712        let reading = {
13713            let ring = Arc::clone(&reader_ring);
13714            std::thread::spawn(move || durable_terminal_history_of(&ring, "reader-module"))
13715        };
13716        started
13717            .recv_timeout(Duration::from_secs(5))
13718            .expect("the history read reached its pause");
13719
13720        let bound = Duration::from_secs(1);
13721        assert!(
13722            record_within("other-module", &other_ring, 20, bound),
13723            "another module's exit waited on a history read (journal writer held)"
13724        );
13725        assert!(
13726            record_within("reader-module", &reader_ring, 30, bound),
13727            "the read module's own exit waited on its history read (ring held)"
13728        );
13729
13730        drop(release);
13731        let paused = reading.join().unwrap();
13732        assert_eq!(
13733            paused.entries.iter().map(|e| e.at_ms).collect::<Vec<_>>(),
13734            vec![10],
13735            "an exit recorded after the read began lands in neither half of it"
13736        );
13737        assert_eq!(paused.journal_skipped_lines, 0);
13738        assert_eq!(paused.journal_read_errors, 0);
13739
13740        let after = durable_terminal_history_of(&reader_ring, "reader-module");
13741        assert_eq!(
13742            after.entries.iter().map(|e| e.at_ms).collect::<Vec<_>>(),
13743            vec![10, 30],
13744            "the next read merges ring and journal with no duplicate"
13745        );
13746        assert_eq!(after.journal_skipped_lines, 0);
13747    }
13748}
13749
13750/// What a restart does with the exited process's output readers. These drive
13751/// the same settle functions the supervisor calls, with a reader the test
13752/// holds, so a reader that has not been scheduled by the bound is a controlled
13753/// input rather than something only a loaded machine produces.
13754#[cfg(test)]
13755mod stderr_settle_tests {
13756    use std::{
13757        future::Future,
13758        io,
13759        pin::Pin,
13760        sync::{Arc, Mutex},
13761        task::{Context, Poll},
13762        time::Duration,
13763    };
13764
13765    use tokio::{
13766        io::{AsyncRead, ReadBuf},
13767        sync::oneshot,
13768        time::Instant,
13769    };
13770
13771    use super::{settle_stderr_pump, settle_stdout_pump, StderrPump};
13772    use crate::stderr_tail::{
13773        pump_stderr_to, pump_stdout_to, split_capture_stamp, untimed, CaptureState,
13774        ChildOutputSink, OutputSink, StderrRing, StderrTailConfig, TailEntry,
13775    };
13776
13777    const BOUND: Duration = Duration::from_millis(250);
13778
13779    /// Yields `before`, then stays pending until the gate is released, then
13780    /// yields `after` and reaches EOF. The bytes after the gate were written
13781    /// by a process that has already exited; only the reader is behind.
13782    struct HeldReader {
13783        before: Option<Vec<u8>>,
13784        gate: Option<oneshot::Receiver<()>>,
13785        after: io::Cursor<Vec<u8>>,
13786    }
13787
13788    impl AsyncRead for HeldReader {
13789        fn poll_read(
13790            mut self: Pin<&mut Self>,
13791            cx: &mut Context<'_>,
13792            buf: &mut ReadBuf<'_>,
13793        ) -> Poll<io::Result<()>> {
13794            if let Some(bytes) = self.before.take() {
13795                buf.put_slice(&bytes);
13796                return Poll::Ready(Ok(()));
13797            }
13798            if let Some(gate) = self.gate.as_mut() {
13799                match Pin::new(gate).poll(cx) {
13800                    Poll::Pending => return Poll::Pending,
13801                    Poll::Ready(_) => self.gate = None,
13802                }
13803            }
13804            Pin::new(&mut self.after).poll_read(cx, buf)
13805        }
13806    }
13807
13808    struct DiscardSink;
13809
13810    impl OutputSink for DiscardSink {
13811        fn write_line(&mut self, _line: &[u8]) {}
13812    }
13813
13814    fn line(text: &str) -> TailEntry {
13815        TailEntry::Line {
13816            text: text.to_string(),
13817            truncated: false,
13818            at_ms: None,
13819        }
13820    }
13821
13822    fn lock(ring: &Arc<Mutex<StderrRing>>) -> std::sync::MutexGuard<'_, StderrRing> {
13823        ring.lock().unwrap()
13824    }
13825
13826    /// Start a reader for a new process generation that delivers `before`
13827    /// immediately and `after` only once the returned sender fires (or is
13828    /// dropped).
13829    fn held_pump(
13830        ring: &Arc<Mutex<StderrRing>>,
13831        before: &str,
13832        after: &str,
13833    ) -> (StderrPump, oneshot::Sender<()>) {
13834        let generation = lock(ring).begin_process();
13835        let (release, gate) = oneshot::channel();
13836        let reader = HeldReader {
13837            before: Some(before.as_bytes().to_vec()),
13838            gate: Some(gate),
13839            after: io::Cursor::new(after.as_bytes().to_vec()),
13840        };
13841        let task = tokio::spawn(pump_stderr_to(
13842            reader,
13843            Arc::clone(ring),
13844            generation,
13845            DiscardSink,
13846        ));
13847        (StderrPump { task, generation }, release)
13848    }
13849
13850    async fn wait_until(ring: &Arc<Mutex<StderrRing>>, done: impl Fn(&StderrRing) -> bool) {
13851        for _ in 0..1000 {
13852            if done(&lock(ring)) {
13853                return;
13854            }
13855            tokio::time::sleep(Duration::from_millis(1)).await;
13856        }
13857        panic!(
13858            "ring never reached the expected state: {:?}",
13859            lock(ring).snapshot(None, None)
13860        );
13861    }
13862
13863    #[tokio::test(start_paused = true)]
13864    async fn a_crash_line_the_reader_had_not_reached_by_the_bound_is_kept_before_the_restart() {
13865        let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
13866        let (pump, release) = held_pump(&ring, "booting\n", "config error: missing storage\n");
13867
13868        settle_stderr_pump("crasher", &ring, pump, BOUND).await;
13869        let before_release = lock(&ring).snapshot(None, None);
13870        assert!(
13871            matches!(before_release.capture, CaptureState::Incomplete { .. }),
13872            "a reader that has not reached EOF cannot claim a whole tail: {before_release:?}"
13873        );
13874
13875        // The restart: the next process starts and writes before the old
13876        // reader catches up.
13877        let next = lock(&ring).begin_process();
13878        lock(&ring).push_line_from(next, "next process booting");
13879        release.send(()).unwrap();
13880        wait_until(&ring, |ring| {
13881            ring.snapshot(None, None).capture == CaptureState::Captured
13882        })
13883        .await;
13884
13885        assert_eq!(
13886            untimed(lock(&ring).snapshot(None, None).entries),
13887            vec![
13888                line("booting"),
13889                line("config error: missing storage"),
13890                TailEntry::ProcessStart,
13891                line("next process booting"),
13892            ],
13893            "the crash's last line must survive a slow reader and stay in the crashed process's section"
13894        );
13895    }
13896
13897    #[tokio::test(start_paused = true)]
13898    async fn a_pipe_held_open_by_a_descendant_reads_incomplete_without_delaying_the_restart_past_the_bound(
13899    ) {
13900        let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
13901        // `_held` is never fired: a descendant keeps the pipe open for the
13902        // whole test.
13903        let (pump, _held) = held_pump(&ring, "parent exiting\n", "");
13904
13905        let started = Instant::now();
13906        settle_stderr_pump("orphaning", &ring, pump, BOUND).await;
13907        assert_eq!(
13908            started.elapsed(),
13909            BOUND,
13910            "the restart must wait exactly the bound for a pipe that stays open, no longer"
13911        );
13912
13913        let next = lock(&ring).begin_process();
13914        lock(&ring).push_line_from(next, "next process booting");
13915        tokio::time::sleep(Duration::from_secs(60)).await;
13916
13917        let snapshot = lock(&ring).snapshot(None, None);
13918        match &snapshot.capture {
13919            CaptureState::Incomplete { reason } => assert!(
13920                reason.contains("had not reached EOF") && reason.contains("250ms"),
13921                "the reason must say what is missing and after how long: {reason}"
13922            ),
13923            other => panic!("expected Incomplete while the pipe is held open, got {other:?}"),
13924        }
13925        assert_eq!(
13926            untimed(snapshot.entries),
13927            vec![
13928                line("parent exiting"),
13929                TailEntry::ProcessStart,
13930                line("next process booting"),
13931            ]
13932        );
13933    }
13934
13935    #[tokio::test(start_paused = true)]
13936    async fn a_reader_that_reaches_eof_within_the_bound_leaves_the_tail_captured() {
13937        let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
13938        let (pump, release) = held_pump(&ring, "one\n", "two\n");
13939        release.send(()).unwrap();
13940
13941        settle_stderr_pump("clean", &ring, pump, BOUND).await;
13942
13943        let snapshot = lock(&ring).snapshot(None, None);
13944        assert_eq!(snapshot.capture, CaptureState::Captured);
13945        assert_eq!(untimed(snapshot.entries), vec![line("one"), line("two")]);
13946    }
13947
13948    #[tokio::test(start_paused = true)]
13949    async fn stdout_buffered_past_the_exit_drain_bound_reaches_the_capture_file() {
13950        const BURST: usize = 150;
13951        let capture = cortexkit_test_support::ScratchDir::new("stdout-exit-drain");
13952        let path = capture.join("two-pipe.stderr.log");
13953        let sink = ChildOutputSink::open(&path, cortexkit_log::Retention::default()).unwrap();
13954        let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
13955        let generation = lock(&ring).begin_process();
13956        let stdout = (0..BURST)
13957            .map(|i| format!("out-{i:04}-{}\n", "o".repeat(64)))
13958            .collect::<String>();
13959        let stderr = (0..BURST)
13960            .map(|i| format!("err-{i:04}-{}\n", "e".repeat(64)))
13961            .collect::<String>();
13962        // Model a child that has written both bursts and exited. Hold the stdout
13963        // reader after one line and a partial prefix, as if pipe readiness or
13964        // capture-file writes had delayed it. The final line also needs EOF to
13965        // flush: neither complete nor partial buffered output may be discarded.
13966        let first_chunk = stdout.find('\n').unwrap() + 1 + "out-0001-".len();
13967        let (release, gate) = oneshot::channel();
13968        let reader = HeldReader {
13969            before: Some(stdout.as_bytes()[..first_chunk].to_vec()),
13970            gate: Some(gate),
13971            after: io::Cursor::new(stdout.as_bytes()[first_chunk..stdout.len() - 1].to_vec()),
13972        };
13973        let (finished, completion) = oneshot::channel();
13974        let stdout_sink = sink.clone();
13975        let pump = tokio::spawn(async move {
13976            pump_stdout_to(reader, stdout_sink).await;
13977            finished.send(()).unwrap();
13978        });
13979        pump_stderr_to(io::Cursor::new(stderr.into_bytes()), ring, generation, sink).await;
13980
13981        let started = Instant::now();
13982        settle_stdout_pump("two-pipe", pump, BOUND).await;
13983        assert_eq!(
13984            started.elapsed(),
13985            BOUND,
13986            "a late reader must not hold up restart"
13987        );
13988        assert_eq!(
13989            std::fs::read_to_string(&path).unwrap().lines().count(),
13990            BURST + 1
13991        );
13992
13993        release
13994            .send(())
13995            .expect("stdout reader must survive the exit drain bound");
13996        tokio::time::timeout(Duration::from_secs(1), completion)
13997            .await
13998            .expect("released stdout reader must reach EOF")
13999            .expect("stdout capture must finish at EOF, not be cancelled");
14000        let contents = std::fs::read_to_string(&path).unwrap();
14001        let mut lines = contents
14002            .lines()
14003            .map(|line| split_capture_stamp(line).expect("capture stamp").1)
14004            .collect::<Vec<_>>();
14005        let mut expected = (0..BURST)
14006            .flat_map(|i| {
14007                [
14008                    format!("out-{i:04}-{}", "o".repeat(64)),
14009                    format!("err-{i:04}-{}", "e".repeat(64)),
14010                ]
14011            })
14012            .collect::<Vec<_>>();
14013        lines.sort_unstable();
14014        expected.sort_unstable();
14015        assert_eq!(
14016            lines, expected,
14017            "both pipes must retain every complete line exactly once"
14018        );
14019    }
14020}
14021
14022/// Containment of a module's process tree (issue #109).
14023///
14024/// The behaviour these defend against is a module helper surviving its module:
14025/// on a real machine the Synapse embedding module's CUDA worker holds ~2.2 GB of
14026/// VRAM, so a leaked grandchild is a leaked GPU allocation, and a day of restarts
14027/// compounds it.
14028///
14029/// They run against the SUPERVISOR rather than the job-object crate because the
14030/// claim is about teardown: a crate-level test proves a job can reap a tree, not
14031/// that the daemon's drain path reaches it.
14032///
14033/// Windows-only, like the mechanism. On Unix this arm compiles out; the cgroup
14034/// lane there is a separate containment path with its own tests.
14035#[cfg(all(test, windows))]
14036mod job_containment_tests {
14037    use super::*;
14038    use cortexkit_test_support::ScratchDir;
14039    use std::{
14040        path::{Path, PathBuf},
14041        sync::{Arc, Mutex},
14042        time::{Duration, Instant},
14043    };
14044
14045    /// The stub, expected beside this test executable.
14046    ///
14047    /// The existence check is here for the reason its twin at `fake_aft_stub_path`
14048    /// documents: `--lib` does not build `[[bin]]` targets, and a bare spawn
14049    /// failure then reads as a broken test rather than an unbuilt dependency.
14050    fn stub_path() -> PathBuf {
14051        let mut path = std::env::current_exe().expect("current_exe available in tests");
14052        path.pop();
14053        path.pop();
14054        path.push("fake-aft-stub.exe");
14055        assert!(
14056            path.exists(),
14057            "fake-aft-stub not built at {}: run `cargo test -p subc-core` (which builds \
14058             [[bin]] targets) rather than `cargo test -p subc-core --lib` (which does not)",
14059            path.display()
14060        );
14061        path
14062    }
14063
14064    /// Poll for the grandchild pid the stub records, and parse it.
14065    fn read_grandchild_pid(path: &Path) -> u32 {
14066        let deadline = Instant::now() + Duration::from_secs(10);
14067        loop {
14068            if let Ok(contents) = std::fs::read_to_string(path) {
14069                if let Ok(pid) = contents.trim().parse() {
14070                    return pid;
14071                }
14072            }
14073            assert!(
14074                Instant::now() < deadline,
14075                "the stub never recorded a grandchild pid at {}",
14076                path.display()
14077            );
14078            std::thread::sleep(Duration::from_millis(10));
14079        }
14080    }
14081
14082    /// Everything one fixture run needs, so the two tests below differ in exactly
14083    /// one place: whether the child is contained.
14084    struct Fixture {
14085        _dir: ScratchDir,
14086        module_id: String,
14087        grandchild: u32,
14088        child: Option<SupervisedChild>,
14089        registry: Arc<Registry>,
14090        snapshot: Arc<Mutex<SupervisorSnapshot>>,
14091        terminal_ring: Arc<Mutex<TerminalRing>>,
14092        spawn_events: SpawnEventFeed,
14093    }
14094
14095    fn fixture(label: &str, module_id: &str) -> Fixture {
14096        let dir = ScratchDir::new(label);
14097        let pid_file = dir.join("grandchild.pid");
14098        let supervisor = Supervisor::new_for_test(
14099            Arc::new(Registry::default()),
14100            RestartPolicy::new(3, Duration::ZERO),
14101        );
14102        let runtime = supervisor.runtime_config();
14103        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
14104        let spec = ModuleSpec {
14105            module_id: module_id.to_string(),
14106            program: stub_path(),
14107            // Zero args deliberately: a `--subc` argument would make the stub dial
14108            // a daemon that is not there, and the failure would land in the same
14109            // stderr ring this fixture exists to keep quiet.
14110            args: Vec::new(),
14111            env: vec![
14112                ("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string()),
14113                (
14114                    "FAKE_AFT_GRANDCHILD_PID_FILE".to_string(),
14115                    pid_file.display().to_string(),
14116                ),
14117            ],
14118            reserved: false,
14119            reserved_prefixes: Vec::new(),
14120            protocol: ModuleProtocol::Subc,
14121            overlap: Default::default(),
14122        };
14123        let child = spawn_and_mark_running(&spec, &runtime, &snapshot)
14124            .expect("spawn the supervised fixture");
14125        let grandchild = read_grandchild_pid(&pid_file);
14126        Fixture {
14127            _dir: dir,
14128            module_id: module_id.to_string(),
14129            grandchild,
14130            child: Some(child),
14131            registry: Arc::new(Registry::default()),
14132            snapshot,
14133            terminal_ring: Arc::clone(&runtime.terminal_ring),
14134            spawn_events: SpawnEventFeed::default(),
14135        }
14136    }
14137
14138    impl Fixture {
14139        /// Drain through the supervisor's own teardown path.
14140        async fn drain(&mut self) {
14141            let child = self
14142                .child
14143                .take()
14144                .expect("the fixture child is still present");
14145            drain_child_to_state(
14146                &self.module_id,
14147                ModuleProtocol::Subc,
14148                // No forwarding table in this fixture, so nothing reaches the
14149                // child over a connection.
14150                StopNotice::NotSent,
14151                &self.registry,
14152                None,
14153                &self.snapshot,
14154                &self.terminal_ring,
14155                &self.spawn_events,
14156                child,
14157                Duration::from_millis(500),
14158                ModuleState::Stopped,
14159                Some(false),
14160            )
14161            .await
14162            .expect("drain the supervised fixture");
14163        }
14164    }
14165
14166    /// Teardown reaps the grandchild, not merely the direct child.
14167    ///
14168    /// This is the assertion the change exists for. Before containment the
14169    /// grandchild survived: it is a separate process, and `start_kill` is
14170    /// `TerminateProcess` scoped to one pid.
14171    #[tokio::test]
14172    async fn teardown_reaps_the_grandchild() {
14173        let mut fixture = fixture("teardown-grandchild", "tree-teardown");
14174        let grandchild = fixture.grandchild;
14175
14176        assert!(
14177            subc_jobobject::process_exists(grandchild),
14178            "grandchild {grandchild} must be alive before teardown, or this proves nothing"
14179        );
14180
14181        fixture.drain().await;
14182
14183        assert!(
14184            subc_jobobject::wait_for_process_exit(grandchild, Duration::from_secs(10)),
14185            "grandchild {grandchild} outlived module teardown: the tree was not contained"
14186        );
14187    }
14188
14189    /// The mutation control: with containment withheld, the grandchild survives
14190    /// the same kill.
14191    ///
14192    /// This is the defect reproduction from #109 — a direct-child kill reaches
14193    /// one pid, and the grandchild is a different process. It spawns OUTSIDE the
14194    /// supervisor because `spawn_and_mark_running` now always contains on
14195    /// Windows, which is the point: there is no longer a path that spawns
14196    /// uncontained, so the control has to construct one.
14197    ///
14198    /// Its job is to keep `teardown_reaps_the_grandchild` honest. If the
14199    /// grandchild ever dies here, that test is passing for a reason unrelated to
14200    /// the job object and the containment claim is unproven.
14201    #[test]
14202    fn an_uncontained_grandchild_survives_a_direct_child_kill() {
14203        let dir = ScratchDir::new("teardown-uncontained");
14204        let pid_file = dir.join("grandchild.pid");
14205        let mut child = std::process::Command::new(stub_path())
14206            .env("FAKE_AFT_NEVER_CONNECT", "1")
14207            .env(
14208                "FAKE_AFT_GRANDCHILD_PID_FILE",
14209                pid_file.display().to_string(),
14210            )
14211            .stdin(std::process::Stdio::null())
14212            .stdout(std::process::Stdio::null())
14213            .stderr(std::process::Stdio::null())
14214            .spawn()
14215            .expect("spawn the uncontained fixture");
14216        let grandchild = read_grandchild_pid(&pid_file);
14217
14218        // Exactly what the pre-fix teardown did: kill the direct child.
14219        child.kill().expect("kill the direct child");
14220        let _ = child.wait();
14221
14222        assert!(
14223            subc_jobobject::process_exists(grandchild),
14224            "grandchild {grandchild} died with the direct child, so this control no longer \
14225             distinguishes contained from uncontained teardown and the regression test is \
14226             passing vacuously"
14227        );
14228
14229        // The orphan this control demonstrates is the leak the fix prevents, so
14230        // the control must not leave one behind.
14231        kill_tree(grandchild);
14232    }
14233
14234    /// Crash durability: closing the containment handle reaps the tree with no
14235    /// teardown code running at all.
14236    ///
14237    /// This is the case `taskkill /T` cannot cover — a daemon that dies cannot
14238    /// call anything — and it is why containment is a kernel property of the
14239    /// handle rather than a step in the drain. Discovered by getting the
14240    /// mutation control wrong: clearing `job` to "disable" containment instead
14241    /// killed the tree, which is the guarantee, not a mistake.
14242    #[tokio::test]
14243    async fn dropping_containment_reaps_the_grandchild() {
14244        let mut fixture = fixture("drop-containment", "tree-drop");
14245        let grandchild = fixture.grandchild;
14246
14247        assert!(subc_jobobject::process_exists(grandchild));
14248
14249        // No `drain` call, no kill: dropping the handle is the entire mechanism.
14250        fixture.child.as_mut().expect("child present").job = None;
14251
14252        assert!(
14253            subc_jobobject::wait_for_process_exit(grandchild, Duration::from_secs(10)),
14254            "grandchild {grandchild} survived the containment handle closing, so a daemon \
14255             crash would leave the tree behind"
14256        );
14257    }
14258
14259    /// Kill a pid and its tree, then confirm it is gone.
14260    fn kill_tree(pid: u32) {
14261        let _ = std::process::Command::new("taskkill.exe")
14262            .args(["/PID", &pid.to_string(), "/T", "/F"])
14263            .stdin(std::process::Stdio::null())
14264            .stdout(std::process::Stdio::null())
14265            .stderr(std::process::Stdio::null())
14266            .status();
14267        assert!(
14268            subc_jobobject::wait_for_process_exit(pid, Duration::from_secs(10)),
14269            "could not clean up grandchild {pid}"
14270        );
14271    }
14272}
14273
14274#[cfg(test)]
14275mod privacy_trampoline_configuration_tests {
14276    #[tokio::test]
14277    #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
14278    async fn macos_spawn_without_a_configured_trampoline_refuses_by_name() {
14279        #[cfg(target_os = "macos")]
14280        {
14281            let supervisor = super::Supervisor::new(
14282                std::sync::Arc::new(crate::Registry::default()),
14283                super::RestartPolicy::default(),
14284            );
14285            let error = supervisor.spawn(spec()).unwrap_err();
14286            assert!(
14287                error
14288                    .to_string()
14289                    .contains("no privacy trampoline configured"),
14290                "{error}"
14291            );
14292        }
14293    }
14294
14295    #[tokio::test]
14296    #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
14297    async fn macos_wrong_trampoline_is_rejected_by_the_startup_probe() {
14298        #[cfg(target_os = "macos")]
14299        {
14300            let supervisor = super::Supervisor::new(
14301                std::sync::Arc::new(crate::Registry::default()),
14302                super::RestartPolicy::default(),
14303            )
14304            .with_privacy_trampoline(std::env::current_exe().unwrap());
14305            let error = supervisor.spawn(spec()).unwrap_err();
14306            assert!(
14307                error
14308                    .to_string()
14309                    .contains("binary does not implement the privacy trampoline protocol"),
14310                "{error}"
14311            );
14312        }
14313    }
14314
14315    #[cfg(target_os = "macos")]
14316    fn spec() -> super::ModuleSpec {
14317        super::ModuleSpec {
14318            module_id: "privacy-configuration".into(),
14319            program: "/bin/sleep".into(),
14320            args: vec!["30".into()],
14321            env: vec![],
14322            reserved: false,
14323            reserved_prefixes: vec![],
14324            protocol: subc_control::ModuleProtocol::None,
14325            overlap: super::ModuleOverlap::Exclusive,
14326        }
14327    }
14328}
14329
14330#[cfg(test)]
14331mod privacy_exec_boundary_tests {
14332    #[cfg(target_os = "macos")]
14333    use super::*;
14334    #[cfg(target_os = "macos")]
14335    use std::{
14336        io::{Read, Write},
14337        net::{TcpListener, TcpStream},
14338    };
14339
14340    /// Unit-test-only pause at the actual early image read, not at a later
14341    /// status read. Production supervisors never inspect this environment key.
14342    #[cfg(target_os = "macos")]
14343    pub(super) fn before_image_sample(spec: &ModuleSpec, pid: u32) {
14344        if let Some((_, path)) = spec
14345            .env
14346            .iter()
14347            .find(|(key, _)| key == "SUBC_TEST_PRIVACY_SAMPLE_BARRIER")
14348        {
14349            let mut barrier = TcpStream::connect(path).unwrap();
14350            barrier
14351                .set_read_timeout(Some(Duration::from_secs(30)))
14352                .unwrap();
14353            barrier.write_all(&pid.to_ne_bytes()).unwrap();
14354            let mut release = [0];
14355            barrier.read_exact(&mut release).unwrap();
14356            assert_eq!(&release, b"X");
14357        }
14358    }
14359
14360    #[cfg(target_os = "macos")]
14361    fn spec(program: &str, args: &[&str]) -> ModuleSpec {
14362        ModuleSpec {
14363            module_id: "privacy-boundary".into(),
14364            program: program.into(),
14365            args: args.iter().map(|arg| (*arg).into()).collect(),
14366            env: vec![],
14367            reserved: false,
14368            reserved_prefixes: vec![],
14369            protocol: ModuleProtocol::None,
14370            overlap: ModuleOverlap::Exclusive,
14371        }
14372    }
14373
14374    #[cfg(target_os = "macos")]
14375    async fn accept(listener: TcpListener) -> TcpStream {
14376        // Socket readiness, not elapsed time, establishes both pause points.
14377        let listener = tokio::net::TcpListener::from_std({
14378            listener.set_nonblocking(true).unwrap();
14379            listener
14380        })
14381        .unwrap();
14382        let (stream, _) = tokio::time::timeout(Duration::from_secs(30), listener.accept())
14383            .await
14384            .unwrap()
14385            .unwrap();
14386        let stream = stream.into_std().unwrap();
14387        stream.set_nonblocking(false).unwrap();
14388        stream
14389            .set_read_timeout(Some(Duration::from_secs(30)))
14390            .unwrap();
14391        stream
14392    }
14393
14394    #[tokio::test]
14395    #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
14396    async fn macos_roster_withholds_a_nonnull_trampoline_image_until_exec_confirmation() {
14397        #[cfg(target_os = "macos")]
14398        {
14399            let root = cortexkit_test_support::ScratchDir::new("privacy-roster-barrier");
14400            // Loopback sockets also work when the replay adapter's TMPDIR is
14401            // longer than Darwin's Unix-domain socket path limit.
14402            let exec_listener = TcpListener::bind("127.0.0.1:0").unwrap();
14403            let sample_listener = TcpListener::bind("127.0.0.1:0").unwrap();
14404            let record = root.join("live-children.json");
14405            let supervisor =
14406                Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
14407                    .with_live_children_record(&record);
14408            let runtime = supervisor.runtime_config();
14409            let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
14410            let mut spec = spec("/bin/sleep", &["30"]);
14411            spec.env = vec![
14412                (
14413                    "SUBC_TEST_PRIVACY_EXEC_BARRIER".into(),
14414                    exec_listener.local_addr().unwrap().to_string(),
14415                ),
14416                (
14417                    "SUBC_TEST_PRIVACY_SAMPLE_BARRIER".into(),
14418                    sample_listener.local_addr().unwrap().to_string(),
14419                ),
14420            ];
14421            let spawn = tokio::task::spawn_blocking(move || {
14422                spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap()
14423            });
14424            let mut sample = accept(sample_listener).await;
14425            let mut pid = [0; 4];
14426            sample.read_exact(&mut pid).unwrap();
14427            let pid = u32::from_ne_bytes(pid);
14428            let mut exec = accept(exec_listener).await;
14429            let mut ready = [0];
14430            exec.read_exact(&mut ready).unwrap();
14431            assert_eq!(&ready, b"R");
14432            // The early read is guaranteed to see a real, non-null trampoline
14433            // image: the fixture has reached its barrier and cannot exec yet.
14434            let trampoline = subc_os::file_identity(&test_privacy_trampoline()).unwrap();
14435            assert_eq!(
14436                observe_spawned_image(pid).unwrap().executable,
14437                Some(trampoline)
14438            );
14439            sample.write_all(b"X").unwrap();
14440            let mut child = spawn.await.unwrap();
14441            let early = crate::live_children::read_record(&record).unwrap();
14442            assert_eq!(early.len(), 1);
14443            assert_eq!(early[0].pid, pid);
14444            assert_eq!(
14445                early[0].executable, None,
14446                "unconfirmed trampoline image entered the roster"
14447            );
14448            assert!(child.report_ready.get().is_none());
14449            // The barrier's duration is unrelated to the production five-second
14450            // exec budget. Start the test's confirmation budget upon release.
14451            child.privacy_exec.as_mut().unwrap().deadline =
14452                tokio::time::Instant::now() + Duration::from_secs(30);
14453            exec.write_all(b"X").unwrap();
14454            child.confirm_privacy_exec().await;
14455            assert_eq!(child.spawn_failure, None);
14456            assert!(child.report_ready.get().is_some());
14457            let confirmed = crate::live_children::read_record(&record).unwrap();
14458            let module = subc_os::file_identity(std::path::Path::new("/bin/sleep")).unwrap();
14459            assert_ne!(module, trampoline);
14460            assert_eq!(confirmed[0].executable, Some(module.into()));
14461            child.start_kill().unwrap();
14462            child.wait().await.unwrap();
14463            child.release_roster();
14464        }
14465    }
14466
14467    #[tokio::test]
14468    #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
14469    async fn macos_already_exited_121_in_try_wait_is_a_module_exit_not_a_trampoline_refusal() {
14470        #[cfg(target_os = "macos")]
14471        {
14472            let registry = Arc::new(Registry::default());
14473            let policy = RestartPolicy::new(0, Duration::ZERO);
14474            let supervisor = Supervisor::new_for_test(Arc::clone(&registry), policy);
14475            let runtime = supervisor.runtime_config();
14476            let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
14477            let spec = spec("/bin/sh", &["-c", "exit 121"]);
14478            // Drive spawn and confirmation separately instead of starting the
14479            // monitor. WNOWAIT observes a real exit without consuming its status,
14480            // so confirmation's first try_wait must take the already-exited arm.
14481            let mut child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
14482            let pid = child.pid;
14483            tokio::task::spawn_blocking(move || {
14484                subc_os::fork_exec_test::wait_for_child_exit_without_reaping(pid)
14485            })
14486            .await
14487            .unwrap()
14488            .unwrap();
14489            child.privacy_exec.as_mut().unwrap().deadline =
14490                tokio::time::Instant::now() + Duration::from_secs(30);
14491            let status = child.wait().await.unwrap();
14492            assert_eq!(status.code(), Some(121));
14493            assert!(child.privacy_exec.is_none());
14494            assert!(
14495                child.report_ready.get().is_none(),
14496                "an exited module must not publish a live pid"
14497            );
14498            let report = classify_reaped_child_exit(&snapshot, &child, &status);
14499            on_child_exit(
14500                &spec,
14501                policy,
14502                &registry,
14503                &snapshot,
14504                &runtime.terminal_ring,
14505                &runtime.spawn_events,
14506                &runtime.child_roster,
14507                report,
14508            )
14509            .await;
14510            let state = lock_snapshot(&snapshot).unwrap();
14511            assert_eq!(state.state, ModuleState::Failed);
14512            assert_eq!(state.last_exit.as_ref().unwrap().code, Some(121));
14513            assert_eq!(state.reported_pid(), None);
14514            drop(state);
14515            let history = runtime.terminal_ring.lock().unwrap().snapshot();
14516            assert_eq!(history.entries.len(), 1);
14517            let terminal = &history.entries[0];
14518            assert_eq!(terminal.exit_code, Some(121));
14519            assert_eq!(terminal.exit_kind, subc_control::TerminalExitKind::Crash);
14520            assert_eq!(terminal.disposition, TerminalDisposition::Failed);
14521            assert_eq!(
14522                terminal.disposition_detail.as_deref(),
14523                Some(policy.budget_exhausted_detail().as_str()),
14524                "module exit 121 was classified as a trampoline refusal: {terminal:?}"
14525            );
14526            assert_eq!(child.spawn_failure, None);
14527            child.release_roster();
14528        }
14529    }
14530}
14531
14532/// The daemon's real spawn path hands a subc-wire child its launch nonce on
14533/// descriptor 3, without an environment copy. The shell records the nonce
14534/// and its environment after exec so these tests observe the real handover.
14535#[cfg(all(test, unix))]
14536mod launch_nonce_descriptor_tests {
14537    use super::{ChildRoster, ModuleProtocol, ModuleSpec, SupervisorHandle};
14538    use crate::stderr_tail::{StderrRing, StderrTailConfig};
14539    use cortexkit_test_support::ScratchDir;
14540    use std::{
14541        path::PathBuf,
14542        sync::{Arc, Mutex},
14543        time::{Duration, Instant},
14544    };
14545
14546    async fn probe(role: super::SpawnRole) {
14547        let scratch = ScratchDir::new("launch-nonce-descriptor");
14548        let fd_copy = scratch.join("from-descriptor");
14549        let env_copy = scratch.join("environment");
14550        let script = format!(
14551            "env > '{env}.tmp' && mv '{env}.tmp' '{env}'; cat <&3 > '{fd}.tmp' && mv '{fd}.tmp' '{fd}'; sleep 30",
14552            fd = fd_copy.display(), env = env_copy.display(),
14553        );
14554        let xdg = |name: &str| (name.to_string(), scratch.join(name).display().to_string());
14555        let spec = ModuleSpec {
14556            module_id: "nonce-descriptor-probe".to_string(),
14557            program: PathBuf::from("/bin/sh"),
14558            args: vec!["-c".to_string(), script],
14559            env: vec![
14560                xdg("XDG_DATA_HOME"),
14561                xdg("XDG_RUNTIME_DIR"),
14562                xdg("XDG_CONFIG_HOME"),
14563                ("SUBC_LAUNCH_NONCE".to_string(), "must-not-leak".to_string()),
14564            ],
14565            reserved: true,
14566            reserved_prefixes: Vec::new(),
14567            protocol: ModuleProtocol::Subc,
14568            overlap: Default::default(),
14569        };
14570        let handle = SupervisorHandle::new();
14571        let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
14572        let roster = ChildRoster::default();
14573        #[cfg(target_os = "macos")]
14574        {
14575            let path = super::test_privacy_trampoline();
14576            roster.set_privacy_trampoline(super::probe_privacy_trampoline(&path).map(|()| path));
14577        }
14578        let child = super::spawn_child_in_slot(
14579            &spec,
14580            None,
14581            Some(&handle),
14582            &ring,
14583            None,
14584            &roster,
14585            #[cfg(target_os = "linux")]
14586            None,
14587            role,
14588            matches!(role, super::SpawnRole::SwapCandidate),
14589        )
14590        .expect("spawn probe");
14591        let deadline = Instant::now() + Duration::from_secs(10);
14592        while !(fd_copy.exists() && env_copy.exists()) {
14593            assert!(Instant::now() < deadline, "probe never wrote its copies");
14594            tokio::time::sleep(Duration::from_millis(20)).await;
14595        }
14596        let nonce = std::fs::read_to_string(fd_copy).unwrap();
14597        assert!(!nonce.is_empty());
14598        let environment = std::fs::read_to_string(env_copy).unwrap();
14599        assert!(environment
14600            .lines()
14601            .any(|line| line.starts_with("SUBC_LAUNCH_NONCE_FD=3:")));
14602        let copy = environment
14603            .lines()
14604            .find_map(|line| line.strip_prefix("SUBC_LAUNCH_NONCE="));
14605        assert_eq!(
14606            copy, None,
14607            "Unix children must never receive the environment nonce"
14608        );
14609        if matches!(role, super::SpawnRole::Plain) {
14610            assert_eq!(
14611                handle.spawn_nonce(&spec.module_id).as_deref(),
14612                Some(nonce.as_str())
14613            );
14614        }
14615        drop(child);
14616    }
14617
14618    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
14619    async fn a_spawned_module_receives_its_nonce_only_on_descriptor_3() {
14620        probe(super::SpawnRole::Plain).await;
14621    }
14622
14623    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
14624    async fn swap_candidate_receives_its_nonce_only_on_descriptor_3() {
14625        probe(super::SpawnRole::SwapCandidate).await;
14626    }
14627}
14628
14629#[cfg(all(test, target_os = "linux"))]
14630mod cgroup_containment_tests {
14631    use super::*;
14632    use cortexkit_test_support::ScratchDir;
14633
14634    fn running(pid: u32) -> bool {
14635        // An orphan can remain a zombie until the container init reaps it.
14636        std::fs::read_to_string(format!("/proc/{pid}/stat"))
14637            .ok()
14638            .and_then(|stat| {
14639                stat.rsplit_once(") ")
14640                    .map(|(_, rest)| rest.starts_with('Z'))
14641            })
14642            .is_some_and(|zombie| !zombie)
14643    }
14644
14645    #[tokio::test]
14646    async fn linux_teardown_reaps_the_grandchild() {
14647        teardown_tree("linux_teardown_reaps_the_grandchild", false).await;
14648    }
14649
14650    #[tokio::test]
14651    async fn linux_shutdown_straggler_reaps_the_grandchild() {
14652        teardown_tree("linux_shutdown_straggler_reaps_the_grandchild", true).await;
14653    }
14654
14655    async fn teardown_tree(test_name: &str, shutdown: bool) {
14656        let dir = ScratchDir::new(test_name);
14657        let root = PathBuf::from(format!(
14658            "/sys/fs/cgroup/subc-tree-test-{test_name}-{}-{}",
14659            std::process::id(),
14660            unix_ms_now()
14661        ));
14662        if let Err(error) = std::fs::create_dir(&root) {
14663            assert!(
14664                std::env::var_os("SUBC_REQUIRE_CGROUP_TEST").is_none(),
14665                "required cgroup test cannot execute: {error}"
14666            );
14667            eprintln!(
14668                "SKIP {test_name}: no writable delegated cgroup at {}: {error}",
14669                root.display()
14670            );
14671            return;
14672        }
14673        let placement = subc_cgroup::prepare_at(&root)
14674            .expect("prepare isolated kernel cgroup")
14675            .expect("isolated cgroup is delegated");
14676        let module_id = "tree-teardown";
14677        let module = placement
14678            .module_path(module_id)
14679            .expect("create isolated module cgroup");
14680        if !module.join("cgroup.kill").exists() {
14681            std::fs::remove_dir(&module).unwrap();
14682            std::fs::remove_dir(root.join("subc-modules")).unwrap();
14683            std::fs::remove_dir(&root).unwrap();
14684            assert!(
14685                std::env::var_os("SUBC_REQUIRE_CGROUP_TEST").is_none(),
14686                "required cgroup.kill interface unavailable"
14687            );
14688            eprintln!("SKIP {test_name}: cgroup.kill unavailable (kernel < 5.14)");
14689            return;
14690        }
14691        let supervisor = Supervisor::new_for_test(
14692            Arc::new(Registry::default()),
14693            RestartPolicy::new(3, Duration::ZERO),
14694        )
14695        .with_cgroup_placement(Some(placement));
14696        let mut runtime = supervisor.runtime_config();
14697        runtime.child_roster = runtime
14698            .child_roster
14699            .for_module(Arc::new(Mutex::new(Duration::from_millis(100))));
14700        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
14701        let pid_file = dir.join("grandchild.pid");
14702        let spec = ModuleSpec {
14703            module_id: module_id.to_string(),
14704            program: PathBuf::from("/bin/sh"),
14705            args: vec![
14706                "-c".into(),
14707                "trap '' TERM; sleep 600 & echo $! > \"$1\"; wait".into(),
14708                "fixture".into(),
14709                pid_file.display().to_string(),
14710            ],
14711            env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
14712                .into_iter()
14713                .map(|key| (key.to_string(), dir.display().to_string()))
14714                .collect(),
14715            reserved: false,
14716            reserved_prefixes: Vec::new(),
14717            protocol: ModuleProtocol::None,
14718            overlap: Default::default(),
14719        };
14720        let child =
14721            spawn_and_mark_running(&spec, &runtime, &snapshot).expect("spawn supervised tree");
14722        let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
14723        let grandchild: u32 = loop {
14724            if let Ok(contents) = std::fs::read_to_string(&pid_file) {
14725                if let Ok(pid) = contents.trim().parse() {
14726                    break pid;
14727                }
14728            }
14729            assert!(
14730                tokio::time::Instant::now() < deadline,
14731                "grandchild pid was not recorded"
14732            );
14733            tokio::time::sleep(Duration::from_millis(10)).await;
14734        };
14735        assert!(
14736            running(grandchild),
14737            "grandchild must be alive before teardown"
14738        );
14739        if shutdown {
14740            let mut child = child;
14741            crate::child_roster::end_children_for_daemon_shutdown(
14742                &runtime.child_roster,
14743                false,
14744                std::future::pending(),
14745            )
14746            .await;
14747            child.wait().await.expect("reap shutdown straggler");
14748        } else {
14749            drain_child_to_state(
14750                module_id,
14751                ModuleProtocol::None,
14752                StopNotice::NotSent,
14753                &Registry::default(),
14754                None,
14755                &snapshot,
14756                &runtime.terminal_ring,
14757                &SpawnEventFeed::default(),
14758                child,
14759                Duration::from_millis(100),
14760                ModuleState::Stopped,
14761                Some(false),
14762            )
14763            .await
14764            .expect("real supervisor teardown");
14765        }
14766        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
14767        while running(grandchild) && tokio::time::Instant::now() < deadline {
14768            tokio::time::sleep(Duration::from_millis(10)).await;
14769        }
14770        let survived = running(grandchild);
14771        // Kill a surviving grandchild so a failed test does not leave it behind.
14772        if survived {
14773            let pid = rustix::process::Pid::from_raw(grandchild as i32).unwrap();
14774            let _ = rustix::process::kill_process(pid, rustix::process::Signal::KILL);
14775            tokio::time::sleep(Duration::from_millis(100)).await;
14776        }
14777        if module.exists() {
14778            std::fs::remove_dir(&module).expect("remove empty module cgroup");
14779        }
14780        std::fs::remove_dir(root.join("subc-modules")).unwrap();
14781        std::fs::remove_dir(&root).unwrap();
14782        assert!(
14783            !survived,
14784            "grandchild {grandchild} outlived module teardown"
14785        );
14786        eprintln!("EXECUTED {test_name}: grandchild {grandchild} killed in isolated cgroup");
14787    }
14788}