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    /// The daemon's Windows per-spawn record: `pipe` after the process it
1007    /// spawned read all nonce bytes, otherwise `env` for the offered copy.
1008    /// List/status reports use this record, not the source the module sends
1009    /// in its registration message's manifest.provenance.launch_nonce_source.
1010    pub launch_nonce_source: Option<String>,
1011    pub state: ModuleState,
1012    pub enabled: bool,
1013    pub process_alive: bool,
1014    pub registration_active: bool,
1015    /// The module's declared wire protocol, carried beside `live` because it is
1016    /// what makes `live` readable: the two fields answer one question together.
1017    /// While a process is alive this is its launch declaration, not a later
1018    /// pending-reload edit. When down it is the configured next launch protocol.
1019    pub protocol: ModuleProtocol,
1020    /// Whether the module is serving, under the strongest definition the daemon
1021    /// can assert for its protocol.
1022    ///
1023    /// A subc module must also be REGISTERED: its process being alive says
1024    /// nothing about whether it can take a request. A `protocol: "none"` module
1025    /// never registers, so that term is dropped and this falls back to "enabled,
1026    /// running, and the process the daemon launched is alive" -- which is all
1027    /// the daemon observes about a process that speaks no subc wire. It stays a
1028    /// `bool` on the wire for compatibility; renderers pair it with `protocol`
1029    /// rather than printing it bare.
1030    pub live: bool,
1031    /// Crash restarts spent INSIDE `restart_window` as of this read. Older
1032    /// restarts have already released their slot, so this count can go down
1033    /// without anybody touching the module.
1034    pub restart_count: u32,
1035    /// Replacement processes spawned over this module's entire supervisor lifetime;
1036    /// unlike `restart_count`, this value is never reset by an operator action
1037    /// and never falls out of a window.
1038    pub lifetime_restarts: u32,
1039    pub spawn_generation: u64,
1040    /// The budget `restart_count` is spent against. Carried alongside the count
1041    /// because the count alone does not say how close the module is to being
1042    /// disabled, and reporting one without the other is what makes an
1043    /// about-to-be-retired module look ordinary.
1044    pub max_restarts: u32,
1045    /// The span `restart_count` is counted over. Carried with the pair above for
1046    /// the same reason they are carried together: "2 of 3" means one thing for a
1047    /// ten-minute window and something else entirely for a lifetime.
1048    pub restart_window: Duration,
1049    /// Effective drain and restart timing policy used by this running module.
1050    /// These values are carried together with the restart budget so status
1051    /// readers can compare configured intent with what the supervisor applied.
1052    pub drain_timeout: Duration,
1053    pub restart_backoff: Duration,
1054    pub restart_max_backoff: Duration,
1055    /// The module's process. On macOS this stays absent while the `ck-subc`
1056    /// launch trampoline is still running in that pid, and appears once the
1057    /// exec acknowledgement confirms the module image has replaced it. Launch
1058    /// time and the supervisor's own hold on the process are unaffected.
1059    pub pid: Option<u32>,
1060    pub spawned_at_ms: Option<u64>,
1061    pub spawned_from: Option<PathBuf>,
1062    pub process_start_time: Option<u64>,
1063    pub last_exit: Option<ExitReport>,
1064    pub health: ModuleHealthStatus,
1065}
1066
1067#[cfg(test)]
1068#[derive(Debug, Clone, Copy, PartialEq)]
1069struct ActorSelectCheckpoint {
1070    generation: u64,
1071    turn: u64,
1072    registered_connection: Option<ConnectionId>,
1073    next_probe_at: Option<Instant>,
1074    wake_after: Option<Duration>,
1075}
1076
1077#[derive(Debug, Clone, PartialEq)]
1078struct SupervisorSnapshot {
1079    #[cfg(windows)]
1080    pipe_consumed: Option<Arc<OnceLock<()>>>,
1081    /// Counts running-child loop turns, not executor polls of a parked wait.
1082    #[cfg(test)]
1083    actor_turns: u64,
1084    /// Running select after exec confirmation and with no unconsumed registry
1085    /// event. Recording a spawn or starting a loop turn is not this barrier.
1086    #[cfg(test)]
1087    actor_select: Option<ActorSelectCheckpoint>,
1088    state: ModuleState,
1089    enabled: bool,
1090    process_alive: bool,
1091    spawned_protocol: Option<ModuleProtocol>,
1092    spawn_failure: Option<String>,
1093    /// When each crash restart was spent, oldest first. This IS the crash
1094    /// budget: its in-window length is the count an operator sees and the count
1095    /// the restart decision is made against, so there is no second counter that
1096    /// can disagree with it. Bounded by `max_restarts`, and cleared by the same
1097    /// operator actions that used to zero the old lifetime counter.
1098    crash_restarts: VecDeque<Instant>,
1099    lifetime_restarts: u32,
1100    /// Successful child spawns in this daemon incarnation.
1101    ///
1102    /// `lifetime_restarts` was considered and rejected: it starts at zero
1103    /// (line 640), successful initial/operator spawns in `set_running` do not
1104    /// increment it (lines 5264-5274), and crash/deliberate retry bookkeeping
1105    /// increments before a successful replacement exists (lines 604, 3846,
1106    /// and 3921), so a failed spawn can consume it. This counter moves only
1107    /// when a live PID is accepted below.
1108    spawn_generation: u64,
1109    pid: Option<u32>,
1110    #[cfg(target_os = "macos")]
1111    report_ready: Option<Arc<OnceLock<()>>>,
1112    /// Last reaped child, retained after current process facts are cleared.
1113    reaped_pid: Option<u32>,
1114    /// Whether the command-serving supervision loop has a scheduled respawn.
1115    respawn_pending: bool,
1116    /// A second restart is waiting for the replacement already scheduled.
1117    coalesced_restart_pending: bool,
1118    spawned_at_ms: Option<u64>,
1119    spawned_from: Option<PathBuf>,
1120    spawned_file_identity: Option<SpawnedFileIdentity>,
1121    process_start_time: Option<u64>,
1122    deliberate_severance: Option<ProcessIdentity>,
1123    last_exit: Option<ExitReport>,
1124    /// Diagnostic attached to the next drain's terminal record, if any.
1125    drain_disposition_detail: Option<String>,
1126    health: ModuleHealthStatus,
1127    /// Whether the current process was started as a swap candidate and so
1128    /// lives in the module's alternate cgroup. The next swap's candidate takes
1129    /// the other one, so the two processes of a swap never share a cgroup. A
1130    /// plain spawn always uses the primary cgroup.
1131    in_alternate_slot: bool,
1132    /// Whether the current `Draining` state ends in a replacement process
1133    /// (restart, reload, health restart) rather than a stop. Only meaningful
1134    /// while `state` is `Draining`; every entry into that state rewrites it.
1135    /// It is what lets route.open answer the retryable `module_reloading` to a
1136    /// consumer that reaches a still-registered process mid-restart, instead of
1137    /// the `supervisor_not_live` a stop or disable deserves.
1138    draining_to_replace: bool,
1139    /// Whether a configuration update has been applied since the current
1140    /// process was spawned, so that process runs an older spec than the one
1141    /// the supervisor now holds. A queued restart is only coalesced into a
1142    /// fresher process when this is false: a restart requested to pick up a
1143    /// new configuration must not be satisfied by a process that predates it.
1144    configuration_updated_since_spawn: bool,
1145}
1146
1147impl SupervisorSnapshot {
1148    /// The pid that status, provenance and resource readings may report. While
1149    /// the launch trampoline still runs in the pid, reading its executable or
1150    /// resource use would describe `ck-subc`, not the module, so none is
1151    /// reported until the exec acknowledgement confirms the module image.
1152    fn reported_pid(&self) -> Option<u32> {
1153        #[cfg(target_os = "macos")]
1154        if self
1155            .report_ready
1156            .as_ref()
1157            .is_some_and(|ready| ready.get().is_none())
1158        {
1159            return None;
1160        }
1161        self.pid
1162    }
1163
1164    fn starting() -> Self {
1165        Self::new(ModuleState::Starting, true)
1166    }
1167
1168    fn disabled() -> Self {
1169        Self::new(ModuleState::Disabled, false)
1170    }
1171
1172    fn failed() -> Self {
1173        Self::new(ModuleState::Failed, true)
1174    }
1175
1176    /// Crash restarts still inside `window`, having dropped the ones that are
1177    /// not. Pruning on read is what makes the budget a rate: an instant older
1178    /// than the window stops holding a slot the moment anybody counts.
1179    fn crash_restarts_in_window(&mut self, window: Duration, now: Instant) -> u32 {
1180        while let Some(oldest) = self.crash_restarts.front() {
1181            if now.duration_since(*oldest) > window {
1182                self.crash_restarts.pop_front();
1183            } else {
1184                break;
1185            }
1186        }
1187        u32::try_from(self.crash_restarts.len()).unwrap_or(u32::MAX)
1188    }
1189
1190    /// Spend one unit of the crash budget and record the restart in the ledger.
1191    ///
1192    /// The ring is bounded by the cap because more than `max_restarts` in-window
1193    /// instants can never be reached (the caller refuses the restart first), so
1194    /// anything beyond that is an unbounded queue waiting to happen.
1195    fn record_crash_restart(&mut self, policy: &RestartPolicy, now: Instant) {
1196        self.crash_restarts.push_back(now);
1197        while self.crash_restarts.len() > policy.max_restarts as usize {
1198            self.crash_restarts.pop_front();
1199        }
1200        self.lifetime_restarts += 1;
1201    }
1202
1203    /// Reserve one crash-restart slot and calculate the delay before respawning.
1204    /// The count is captured before recording this restart, so the first retry
1205    /// uses the base delay and each later in-window retry escalates once.
1206    fn next_crash_restart(
1207        &mut self,
1208        policy: &RestartPolicy,
1209        now: Instant,
1210    ) -> Option<CrashRestartSchedule> {
1211        let restart_in_window = self.crash_restarts_in_window(policy.window, now);
1212        if restart_in_window >= policy.max_restarts {
1213            return None;
1214        }
1215        self.record_crash_restart(policy, now);
1216        Some(CrashRestartSchedule {
1217            restart_in_window,
1218            delay: policy.delay_for_restart(restart_in_window),
1219        })
1220    }
1221
1222    /// Give the module its full budget back, as an operator restart, reload, or
1223    /// re-enable does. `lifetime_restarts` deliberately does not move: it is the
1224    /// ledger of what actually happened, and an operator action does not unmake
1225    /// the crashes.
1226    fn clear_crash_restarts(&mut self) {
1227        self.crash_restarts.clear();
1228    }
1229
1230    fn new(state: ModuleState, enabled: bool) -> Self {
1231        Self {
1232            #[cfg(test)]
1233            actor_turns: 0,
1234            #[cfg(test)]
1235            actor_select: None,
1236            state,
1237            enabled,
1238            process_alive: false,
1239            #[cfg(windows)]
1240            pipe_consumed: None,
1241            spawned_protocol: None,
1242            spawn_failure: None,
1243            crash_restarts: VecDeque::new(),
1244            lifetime_restarts: 0,
1245            spawn_generation: 0,
1246            pid: None,
1247            #[cfg(target_os = "macos")]
1248            report_ready: None,
1249            reaped_pid: None,
1250            respawn_pending: false,
1251            coalesced_restart_pending: false,
1252            spawned_at_ms: None,
1253            spawned_from: None,
1254            spawned_file_identity: None,
1255            process_start_time: None,
1256            deliberate_severance: None,
1257            last_exit: None,
1258            drain_disposition_detail: None,
1259            health: ModuleHealthStatus::default(),
1260            in_alternate_slot: false,
1261            draining_to_replace: false,
1262            configuration_updated_since_spawn: false,
1263        }
1264    }
1265}
1266
1267type SharedSnapshot = Arc<Mutex<SupervisorSnapshot>>;
1268
1269type SpawnSubscriberKey = (ConnectionId, u64);
1270
1271#[derive(Debug)]
1272struct SpawnSubscriber {
1273    version: u8,
1274    frames: mpsc::Sender<Frame>,
1275    /// Tells this subscriber's forwarder that it was dropped for lagging, and
1276    /// from which event. The full frame channel cannot carry that news, so it
1277    /// travels beside it; see `SpawnEventFeed::subscribe`.
1278    lagged: Option<oneshot::Sender<SpawnCursor>>,
1279}
1280
1281#[derive(Debug)]
1282struct SpawnEventState {
1283    daemon_incarnation: String,
1284    seq: u64,
1285    capacity: usize,
1286    live: HashMap<String, LiveSpawn>,
1287    generations: HashMap<String, u64>,
1288    events: VecDeque<SpawnEvent>,
1289    subscribers: HashMap<SpawnSubscriberKey, SpawnSubscriber>,
1290}
1291
1292impl Default for SpawnEventState {
1293    fn default() -> Self {
1294        Self {
1295            daemon_incarnation: "unconfigured".to_string(),
1296            seq: 0,
1297            capacity: SPAWN_EVENT_RING_CAPACITY,
1298            live: HashMap::new(),
1299            generations: HashMap::new(),
1300            events: VecDeque::new(),
1301            subscribers: HashMap::new(),
1302        }
1303    }
1304}
1305
1306#[derive(Debug, Clone, Default)]
1307struct SpawnEventFeed(
1308    Arc<Mutex<SpawnEventState>>,
1309    #[cfg(test)] Arc<Mutex<Option<std::net::TcpStream>>>,
1310);
1311
1312#[derive(Debug, Clone, PartialEq, Eq)]
1313pub(crate) enum SpawnSubscribeRefusal {
1314    ForeignIncarnation { current: String },
1315    TooOld { oldest: SpawnCursor },
1316    Frame(String),
1317}
1318
1319impl SpawnEventFeed {
1320    fn configure_incarnation(&self, daemon_incarnation: String) {
1321        let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1322        state.daemon_incarnation = daemon_incarnation;
1323        state.seq = 0;
1324        state.live.clear();
1325        state.generations.clear();
1326        state.events.clear();
1327        state.subscribers.clear();
1328    }
1329
1330    fn cursor(state: &SpawnEventState) -> SpawnCursor {
1331        SpawnCursor {
1332            daemon_incarnation: state.daemon_incarnation.clone(),
1333            seq: state.seq,
1334        }
1335    }
1336
1337    fn snapshot(&self) -> SpawnSnapshot {
1338        let state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1339        let mut live = state.live.values().cloned().collect::<Vec<_>>();
1340        live.sort_by(|left, right| left.module_id.cmp(&right.module_id));
1341        SpawnSnapshot {
1342            cursor: Self::cursor(&state),
1343            ring_bound: state.capacity as u64,
1344            live,
1345        }
1346    }
1347
1348    fn emit_spawned(&self, module_id: &str, pid: u32, spawned_at_ms: u64) -> u64 {
1349        let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1350        let generation = state
1351            .generations
1352            .get(module_id)
1353            .copied()
1354            .unwrap_or(0)
1355            .checked_add(1)
1356            .expect("spawn generation exhausted");
1357        state.generations.insert(module_id.to_string(), generation);
1358        let live = LiveSpawn {
1359            module_id: module_id.to_string(),
1360            spawn_generation: generation,
1361            pid,
1362            spawned_at_ms,
1363        };
1364        state.live.insert(module_id.to_string(), live);
1365        Self::emit_locked(
1366            &mut state,
1367            SpawnEventKind::Spawned,
1368            module_id.to_string(),
1369            generation,
1370            pid,
1371            None,
1372            None,
1373        );
1374        generation
1375    }
1376
1377    fn emit_exited(&self, module_id: &str, exit_code: Option<i32>, exit_signal: Option<i32>) {
1378        let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1379        let Some(live) = state.live.remove(module_id) else {
1380            warn!(
1381                module_id,
1382                "terminal record had no live spawn event identity"
1383            );
1384            return;
1385        };
1386        Self::emit_locked(
1387            &mut state,
1388            SpawnEventKind::Exited,
1389            module_id.to_string(),
1390            live.spawn_generation,
1391            live.pid,
1392            exit_code,
1393            exit_signal,
1394        );
1395    }
1396
1397    /// Report the exit of a process that a swap has already replaced.
1398    ///
1399    /// `emit_exited` removes the module's live entry, which after a swap's
1400    /// cutover describes the promoted candidate, not the old process now
1401    /// exiting. This emits the old generation's exit and leaves the live entry
1402    /// alone unless it still names that generation.
1403    fn emit_superseded_exited(
1404        &self,
1405        module_id: &str,
1406        spawn_generation: u64,
1407        pid: u32,
1408        exit_code: Option<i32>,
1409        exit_signal: Option<i32>,
1410    ) {
1411        let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1412        if state
1413            .live
1414            .get(module_id)
1415            .is_some_and(|live| live.spawn_generation == spawn_generation)
1416        {
1417            state.live.remove(module_id);
1418        }
1419        Self::emit_locked(
1420            &mut state,
1421            SpawnEventKind::Exited,
1422            module_id.to_string(),
1423            spawn_generation,
1424            pid,
1425            exit_code,
1426            exit_signal,
1427        );
1428    }
1429
1430    #[allow(clippy::too_many_arguments)]
1431    fn emit_locked(
1432        state: &mut SpawnEventState,
1433        kind: SpawnEventKind,
1434        module_id: String,
1435        spawn_generation: u64,
1436        pid: u32,
1437        exit_code: Option<i32>,
1438        exit_signal: Option<i32>,
1439    ) {
1440        state.seq = state
1441            .seq
1442            .checked_add(1)
1443            .expect("spawn event sequence exhausted");
1444        let event = SpawnEvent {
1445            cursor: Self::cursor(state),
1446            kind,
1447            module_id,
1448            spawn_generation,
1449            pid,
1450            exit_code,
1451            exit_signal,
1452        };
1453        state.events.push_back(event.clone());
1454        while state.events.len() > state.capacity {
1455            state.events.pop_front();
1456        }
1457        let body = match serde_json::to_vec(&event) {
1458            Ok(body) => body,
1459            Err(error) => {
1460                error!(%error, "failed to serialize supervisor spawn event");
1461                return;
1462            }
1463        };
1464        state.subscribers.retain(|(connection_id, corr), subscriber| {
1465            let frame = Frame::build_with_version(
1466                subscriber.version,
1467                FrameType::StreamData,
1468                control_flags(),
1469                0,
1470                0,
1471                *corr,
1472                body.clone(),
1473            );
1474            match frame {
1475                Ok(frame) => {
1476                    if subscriber.frames.try_send(frame).is_ok() {
1477                        true
1478                    } else {
1479                        warn!(connection_id = connection_id.get(), corr, "dropping lagged supervisor spawn subscriber");
1480                        if let Some(lagged) = subscriber.lagged.take() {
1481                            let _ = lagged.send(event.cursor.clone());
1482                        }
1483                        false
1484                    }
1485                }
1486                Err(error) => {
1487                    warn!(connection_id = connection_id.get(), corr, %error, "dropping supervisor spawn subscriber after frame build failure");
1488                    false
1489                }
1490            }
1491        });
1492    }
1493
1494    fn subscribe(
1495        &self,
1496        connection_id: ConnectionId,
1497        corr: u64,
1498        version: u8,
1499        since: Option<SpawnCursor>,
1500        sink: FrameSink,
1501    ) -> Result<(), SpawnSubscribeRefusal> {
1502        let (frames, mut receiver) = mpsc::channel(SPAWN_SUBSCRIBER_BUFFER);
1503        let (lagged, mut lagged_rx) = oneshot::channel::<SpawnCursor>();
1504        {
1505            let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1506            let replay = if let Some(since) = since {
1507                if since.daemon_incarnation != state.daemon_incarnation {
1508                    return Err(SpawnSubscribeRefusal::ForeignIncarnation {
1509                        current: state.daemon_incarnation.clone(),
1510                    });
1511                }
1512                if let Some(oldest) = state.events.front().map(|event| event.cursor.clone()) {
1513                    if since.seq < oldest.seq.saturating_sub(1) {
1514                        return Err(SpawnSubscribeRefusal::TooOld { oldest });
1515                    }
1516                }
1517                state
1518                    .events
1519                    .iter()
1520                    .filter(|event| event.cursor.seq > since.seq)
1521                    .cloned()
1522                    .collect::<Vec<_>>()
1523            } else {
1524                Vec::new()
1525            };
1526            for event in replay {
1527                let body = serde_json::to_vec(&event)
1528                    .map_err(|error| SpawnSubscribeRefusal::Frame(error.to_string()))?;
1529                let frame = Frame::build_with_version(
1530                    version,
1531                    FrameType::StreamData,
1532                    control_flags(),
1533                    0,
1534                    0,
1535                    corr,
1536                    body,
1537                )
1538                .map_err(|error| SpawnSubscribeRefusal::Frame(error.to_string()))?;
1539                frames
1540                    .try_send(frame)
1541                    .map_err(|error| SpawnSubscribeRefusal::Frame(error.to_string()))?;
1542            }
1543            state.subscribers.insert(
1544                (connection_id, corr),
1545                SpawnSubscriber {
1546                    version,
1547                    frames,
1548                    lagged: Some(lagged),
1549                },
1550            );
1551        }
1552        // The lagged terminal is sent here, by the forwarder, rather than by
1553        // the emitter: at the moment of the drop the subscriber's own channel
1554        // is full, and writing to the connection sink directly from the emitter
1555        // would put the Error AHEAD of the events still queued in that channel
1556        // (and the emitter holds the feed lock, so it cannot await the sink).
1557        // Dropping the subscriber drops the only sender, so `recv` drains every
1558        // queued event and then returns `None`; only then is the Error sent, so
1559        // the client sees each event it can keep, then the reason it was cut.
1560        // Cancel and connection removal drop the oneshot unsent, so they end
1561        // the stream with no Error.
1562        tokio::spawn(async move {
1563            while let Some(frame) = receiver.recv().await {
1564                if sink.send(frame).await.is_err() {
1565                    return;
1566                }
1567            }
1568            let Ok(first_undelivered) = lagged_rx.try_recv() else {
1569                return;
1570            };
1571            match spawn_subscriber_lagged_frame(version, corr, first_undelivered) {
1572                Ok(frame) => {
1573                    let _ = sink.send(frame).await;
1574                }
1575                Err(error) => {
1576                    error!(%error, corr, "failed to build lagged spawn subscriber terminal frame");
1577                }
1578            }
1579        });
1580        Ok(())
1581    }
1582
1583    fn cancel(&self, connection_id: ConnectionId, corr: u64) -> bool {
1584        let Some(subscriber) = self
1585            .0
1586            .lock()
1587            .unwrap_or_else(|p| p.into_inner())
1588            .subscribers
1589            .remove(&(connection_id, corr))
1590        else {
1591            return false;
1592        };
1593        if let Ok(frame) = Frame::build_with_version(
1594            subscriber.version,
1595            FrameType::StreamEnd,
1596            control_flags(),
1597            0,
1598            0,
1599            corr,
1600            Vec::new(),
1601        ) {
1602            tokio::spawn(async move {
1603                let _ = subscriber.frames.send(frame).await;
1604            });
1605        }
1606        true
1607    }
1608
1609    fn remove_connection(&self, connection_id: ConnectionId) {
1610        self.0
1611            .lock()
1612            .unwrap_or_else(|p| p.into_inner())
1613            .subscribers
1614            .retain(|(subscriber_connection, _), _| *subscriber_connection != connection_id);
1615    }
1616
1617    #[cfg(any(test, feature = "test-support"))]
1618    fn set_capacity(&self, capacity: usize) {
1619        self.0.lock().unwrap_or_else(|p| p.into_inner()).capacity = capacity;
1620    }
1621
1622    #[cfg(any(test, feature = "test-support"))]
1623    fn subscriber_count(&self) -> usize {
1624        self.0
1625            .lock()
1626            .unwrap_or_else(|p| p.into_inner())
1627            .subscribers
1628            .len()
1629    }
1630}
1631
1632/// Narrow process-liveness signal published by supervisors and consumed by passive liveness polls.
1633/// The terminal Error a lagged spawn subscriber receives after its queued events.
1634fn spawn_subscriber_lagged_frame(
1635    version: u8,
1636    corr: u64,
1637    first_undelivered: SpawnCursor,
1638) -> Result<Frame, String> {
1639    let body = serde_json::to_vec(&subc_protocol::ErrorBody {
1640        code: SPAWN_SUBSCRIBER_LAGGED_CODE.to_string(),
1641        message: "spawn subscriber fell behind and was dropped; resubscribe from the last cursor received"
1642            .to_string(),
1643        detail: Some(serde_json::json!({
1644            "first_undelivered_cursor": first_undelivered
1645        })),
1646    })
1647    .map_err(|error| error.to_string())?;
1648    Frame::build_with_version(version, FrameType::Error, control_flags(), 0, 0, corr, body)
1649        .map_err(|error| error.to_string())
1650}
1651
1652pub trait ModuleProcessLiveness: Send + Sync {
1653    fn process_live(&self, module_id: &str) -> Option<bool>;
1654
1655    /// Whether the supervisor is replacing this module's process right now: an
1656    /// operator restart or reload, a health restart, or a crash respawn whose
1657    /// backoff is running. A module in that state is not live, but a consumer
1658    /// refused now should retry shortly rather than treat the target as gone.
1659    /// Stopped, failed, and disabled modules are not replacing.
1660    fn process_replacing(&self, _module_id: &str) -> bool {
1661        false
1662    }
1663}
1664
1665/// Shared process-liveness registry keyed by supervised `module_id`.
1666#[derive(Debug, Clone, Default)]
1667pub struct SupervisorProcessLiveness {
1668    snapshots: Arc<Mutex<HashMap<String, SharedSnapshot>>>,
1669}
1670
1671impl SupervisorProcessLiveness {
1672    pub fn new() -> Self {
1673        Self::default()
1674    }
1675
1676    fn track(&self, module_id: String, snapshot: SharedSnapshot) {
1677        let mut snapshots = self
1678            .snapshots
1679            .lock()
1680            .unwrap_or_else(|poisoned| poisoned.into_inner());
1681        snapshots.insert(module_id, snapshot);
1682    }
1683
1684    fn untrack_if_current(&self, module_id: &str, snapshot: &SharedSnapshot) {
1685        let mut snapshots = self
1686            .snapshots
1687            .lock()
1688            .unwrap_or_else(|poisoned| poisoned.into_inner());
1689        let is_current = snapshots
1690            .get(module_id)
1691            .map(|tracked| Arc::ptr_eq(tracked, snapshot))
1692            .unwrap_or(false);
1693        if is_current {
1694            snapshots.remove(module_id);
1695        }
1696    }
1697}
1698
1699impl ModuleProcessLiveness for SupervisorProcessLiveness {
1700    fn process_live(&self, module_id: &str) -> Option<bool> {
1701        let snapshot = {
1702            let snapshots = self
1703                .snapshots
1704                .lock()
1705                .unwrap_or_else(|poisoned| poisoned.into_inner());
1706            snapshots.get(module_id).cloned()
1707        }?;
1708        let snapshot = snapshot
1709            .lock()
1710            .unwrap_or_else(|poisoned| poisoned.into_inner());
1711        Some(snapshot.state == ModuleState::Running && snapshot.process_alive)
1712    }
1713
1714    fn process_replacing(&self, module_id: &str) -> bool {
1715        let Some(snapshot) = self
1716            .snapshots
1717            .lock()
1718            .unwrap_or_else(|poisoned| poisoned.into_inner())
1719            .get(module_id)
1720            .cloned()
1721        else {
1722            return false;
1723        };
1724        let snapshot = snapshot
1725            .lock()
1726            .unwrap_or_else(|poisoned| poisoned.into_inner());
1727        snapshot.enabled
1728            && match snapshot.state {
1729                ModuleState::Restarting => true,
1730                ModuleState::Draining => snapshot.draining_to_replace,
1731                ModuleState::Starting
1732                | ModuleState::Running
1733                | ModuleState::Unresponsive
1734                | ModuleState::Stopped
1735                | ModuleState::Failed
1736                | ModuleState::Disabled => false,
1737            }
1738    }
1739}
1740
1741#[cfg(test)]
1742#[derive(Debug, Default)]
1743struct ReloadExitRecordGate {
1744    reached: tokio::sync::Notify,
1745    resume: tokio::sync::Notify,
1746}
1747
1748#[derive(Debug, Clone, Copy)]
1749enum RespawnKind {
1750    Spawn,
1751    Reload,
1752}
1753
1754#[derive(Debug, Clone, Copy)]
1755struct PendingRespawn {
1756    deadline: Instant,
1757    kind: RespawnKind,
1758}
1759
1760type ReloadReply = oneshot::Sender<Result<(), SuperviseError>>;
1761
1762#[derive(Debug, Clone)]
1763struct SupervisorRuntimeConfig {
1764    /// Restart operations hand their backoff to the loop, which keeps serving commands.
1765    scheduled_respawn: Arc<Mutex<Option<PendingRespawn>>>,
1766    /// A reload acknowledges completion only after its replacement registers.
1767    deferred_reload_reply: Arc<Mutex<Option<ReloadReply>>>,
1768    restart_policy: RestartPolicy,
1769    /// This module's RESOLVED drain budget: per-module config when present,
1770    /// else `default_drain_timeout`.
1771    drain_timeout: Duration,
1772    /// Shared with the status handle so the attested value changes atomically
1773    /// when a rescan updates the running drain policy.
1774    effective_drain_timeout: Arc<Mutex<Duration>>,
1775    /// The supervisor-wide fallback, kept so a configuration update that
1776    /// REMOVES the per-module override can re-resolve to it.
1777    default_drain_timeout: Duration,
1778    health: HealthConfig,
1779    connection_file_path: Option<PathBuf>,
1780    capture_logs_dir: Option<PathBuf>,
1781    forwarding: Option<Arc<ForwardingTable>>,
1782    /// The shared handle, so every spawn path (initial, restart, reload) records the
1783    /// reserved-module launch nonce the HELLO verifier checks against.
1784    supervisor_handle: Option<SupervisorHandle>,
1785    /// This module's stderr tail, shared with the [`SupervisedModule`] that answers
1786    /// status queries.
1787    ///
1788    /// One ring per module, held across every respawn. The lines explaining an exit
1789    /// are written BEFORE that exit, so a ring recreated per process would be empty
1790    /// exactly when it is asked for.
1791    stderr_ring: Arc<Mutex<StderrRing>>,
1792    terminal_ring: Arc<Mutex<TerminalRing>>,
1793    spawn_events: SpawnEventFeed,
1794    child_roster: ChildRoster,
1795    #[cfg(target_os = "linux")]
1796    cgroup_placement: Option<subc_cgroup::Placement>,
1797    #[cfg(test)]
1798    test_seed_stale_facts_before_enable_spawn: bool,
1799    #[cfg(test)]
1800    test_reload_exit_record_gate: Option<Arc<ReloadExitRecordGate>>,
1801}
1802
1803#[derive(Debug, Clone, PartialEq, Eq)]
1804struct SupervisedConfiguration {
1805    spec: ModuleSpec,
1806    health: HealthConfig,
1807}
1808
1809/// Shared daemon lookup table for supervised module handles.
1810///
1811/// Shared by clone between the [`Supervisor`] (which spawns processes) and the
1812/// channel-0 control handler (which verifies HELLOs and consumer route opens), so
1813/// launch nonces recorded at spawn are checked by the same daemon instance.
1814#[derive(Debug, Clone, Default)]
1815pub struct SupervisorHandle {
1816    modules: Arc<Mutex<HashMap<String, SupervisedModule>>>,
1817    /// Module ids the supervisor has taken on. An id is added BEFORE the
1818    /// module's first process is spawned and removed only when the module
1819    /// leaves the roster (see [`Self::retire`]), so it is always a superset of
1820    /// the keys of `modules`.
1821    ///
1822    /// `modules` cannot answer "is this module configured?" on its own: a
1823    /// [`SupervisedModule`] only exists once its process has been spawned, and
1824    /// a fast child can connect, register, sync its scopes and ask about them
1825    /// before the supervisor has inserted it. Answering "not configured" in that
1826    /// gap makes scope admission refuse with the terminal "will never sync"
1827    /// instead of the retryable "has not synced yet".
1828    configured_ids: Arc<Mutex<HashSet<String>>>,
1829    spawn_events: SpawnEventFeed,
1830    /// The current expected launch nonce for each reserved module_id. Set when the
1831    /// supervisor spawns the reserved module; checked when a HELLO claims that id. A
1832    /// non-reserved module never has an entry here and is never nonce-checked.
1833    /// Reserved module ids and the nonce that authorizes their next HELLO.
1834    /// `None` means RESERVED WITH NO LEGITIMATE HOLDER — a reserved module that
1835    /// has never been spawned (e.g. configured `enabled: false`) — and refuses
1836    /// every HELLO. Before this was expressible, a reserved-but-never-spawned id
1837    /// had NO entry and admitted anyone: the reservation protected the nonce
1838    /// holder, not the NAME (found live by CKCRED's canary probe registering
1839    /// against a reserved scratch id).
1840    reserved_nonces: Arc<Mutex<HashMap<String, Option<String>>>>,
1841    /// Module ids removed by an executed rescan and the unix-millisecond removal time.
1842    ///
1843    /// This is deliberately in-memory only: subc is state-free across daemon
1844    /// restarts, and the tombstone only explains the hours-after-removal window
1845    /// while this executing daemon is still alive. Do not persist it in a store.
1846    removal_tombstones: Arc<Mutex<HashMap<String, u64>>>,
1847    /// The current launch nonce for every supervised spawn. This is separate from
1848    /// reserved_nonces because consumer route.open attestation applies to all spawned
1849    /// modules, while HELLO id-squatting protection remains opt-in via `reserved`.
1850    spawn_nonces: Arc<Mutex<HashMap<String, String>>>,
1851    /// Reserved namespace prefixes mapped to the supervised owner module whose
1852    /// current spawn nonce authorizes HELLO claims below the prefix.
1853    ///
1854    /// Per §2.6 this is not a same-user security barrier: a same-user process can
1855    /// read the key file and launch nonce env. Like exact reserved ids, it prevents
1856    /// accidental collisions and lower-trust processes from squatting protected
1857    /// namespaces.
1858    reserved_prefix_owners: Arc<Mutex<HashMap<String, String>>>,
1859    /// Blue/green swaps in progress, by module id. An entry exists from just
1860    /// before the candidate process is spawned until the swap has failed, or
1861    /// has cut over and the old process is gone. While it exists, HELLO for the
1862    /// id is gated on the swap token (see [`Self::swap_hello_admission`]) and
1863    /// consumer attestation accepts both processes' nonces.
1864    swaps: Arc<Mutex<HashMap<String, OpenSwap>>>,
1865    /// Told when a swap promotes its candidate; see [`SwapPromotionObserver`].
1866    promotion_observer: PromotionObserverSlot,
1867    /// Serializes module-set reconciliation with operator lifecycle commands. Without
1868    /// this daemon-wide ordering, a rescan could retire or update a module while a
1869    /// concurrent reload still held its old handle and launch specification.
1870    operation_lock: Arc<AsyncMutex<()>>,
1871}
1872
1873/// Told when a swap has promoted its candidate to be the module's active
1874/// registration.
1875///
1876/// An ordinary HELLO runs the control plane's registration side effects (the
1877/// capability cache, the deny census, the requirement recompute) as it
1878/// registers. A swap candidate's HELLO does not, because it is not routable;
1879/// promotion is when those must run instead, and promotion happens in the
1880/// supervisor, which has no other way into the control handler.
1881pub(crate) trait SwapPromotionObserver: Send + Sync {
1882    fn swap_promoted(&self, registration: &crate::registry::ModuleRegistration);
1883}
1884
1885/// The installed [`SwapPromotionObserver`], held weakly: the observer (the
1886/// control handler) owns this handle, so a strong reference back would be a
1887/// cycle that keeps both alive.
1888#[derive(Clone, Default)]
1889struct PromotionObserverSlot(Arc<Mutex<Option<std::sync::Weak<dyn SwapPromotionObserver>>>>);
1890
1891impl fmt::Debug for PromotionObserverSlot {
1892    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1893        f.write_str("PromotionObserverSlot")
1894    }
1895}
1896
1897/// The nonces of one open swap.
1898#[derive(Debug, Clone)]
1899struct OpenSwap {
1900    /// The launch nonce minted for the candidate process. It is the swap
1901    /// token: the only thing that admits a HELLO into the candidate slot.
1902    candidate_nonce: String,
1903    /// The incumbent's launch nonce, captured when the swap opened. It is kept
1904    /// here because cutover moves the module's recorded spawn nonce to the
1905    /// candidate while the incumbent is still draining and its consumers are
1906    /// still attesting with this one.
1907    incumbent_nonce: Option<String>,
1908    /// Set once a HELLO has been admitted with the swap token, so the token
1909    /// admits one registration and cannot be replayed after cutover empties
1910    /// the candidate slot.
1911    candidate_admitted: bool,
1912}
1913
1914/// What the swap gate says about a HELLO. See
1915/// [`SupervisorHandle::swap_hello_admission`].
1916#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1917pub(crate) enum SwapHelloAdmission {
1918    /// No swap is open for the id (or the HELLO carries the incumbent's own
1919    /// nonce); the ordinary gates decide.
1920    NotSwapping,
1921    /// The HELLO carries the swap token: register it into the candidate slot.
1922    Candidate,
1923    /// A swap is open and the HELLO carries a nonce the supervisor did not
1924    /// mint for this id, no nonce, or a token already used.
1925    Refused,
1926}
1927
1928#[derive(Debug, Clone, PartialEq, Eq)]
1929pub(crate) enum ReservedHelloRejection {
1930    Exact {
1931        module_id: String,
1932    },
1933    Prefix {
1934        prefix: String,
1935        owner_module_id: String,
1936    },
1937}
1938
1939impl SupervisorHandle {
1940    pub fn new() -> Self {
1941        Self::default()
1942    }
1943
1944    pub(crate) fn spawn_snapshot(&self) -> SpawnSnapshot {
1945        self.spawn_events.snapshot()
1946    }
1947
1948    pub(crate) fn subscribe_spawns(
1949        &self,
1950        connection_id: ConnectionId,
1951        corr: u64,
1952        version: u8,
1953        since: Option<SpawnCursor>,
1954        sink: FrameSink,
1955    ) -> Result<(), SpawnSubscribeRefusal> {
1956        self.spawn_events
1957            .subscribe(connection_id, corr, version, since, sink)
1958    }
1959
1960    pub(crate) fn cancel_spawn_subscription(&self, connection_id: ConnectionId, corr: u64) -> bool {
1961        self.spawn_events.cancel(connection_id, corr)
1962    }
1963
1964    pub(crate) fn remove_spawn_subscribers(&self, connection_id: ConnectionId) {
1965        self.spawn_events.remove_connection(connection_id);
1966    }
1967
1968    #[cfg(any(test, feature = "test-support"))]
1969    pub fn set_spawn_event_capacity_for_test(&self, capacity: usize) {
1970        assert!(capacity > 0, "spawn event capacity must be non-zero");
1971        self.spawn_events.set_capacity(capacity);
1972    }
1973
1974    #[cfg(any(test, feature = "test-support"))]
1975    pub fn spawn_subscriber_count_for_test(&self) -> usize {
1976        self.spawn_events.subscriber_count()
1977    }
1978
1979    /// Record the launch nonce from a supervised spawn, replacing any prior nonce so
1980    /// a respawn invalidates stale consumer identities.
1981    pub fn set_spawn_nonce(&self, module_id: &str, nonce: String) {
1982        self.spawn_nonces
1983            .lock()
1984            .unwrap_or_else(|poisoned| poisoned.into_inner())
1985            .insert(module_id.to_string(), nonce);
1986    }
1987
1988    /// Record the launch nonce expected from the next HELLO for a reserved module,
1989    /// replacing any prior nonce (a respawn invalidates the previous one).
1990    pub fn set_reserved_nonce(&self, module_id: &str, nonce: String) {
1991        self.reserved_nonces
1992            .lock()
1993            .unwrap_or_else(|poisoned| poisoned.into_inner())
1994            .insert(module_id.to_string(), Some(nonce));
1995    }
1996
1997    /// Record namespace prefixes owned by a supervised module.
1998    pub fn set_reserved_prefixes(&self, owner_module_id: &str, prefixes: &[String]) {
1999        let mut owners = self
2000            .reserved_prefix_owners
2001            .lock()
2002            .unwrap_or_else(|poisoned| poisoned.into_inner());
2003        owners.retain(|_, owner| owner != owner_module_id);
2004        for prefix in prefixes {
2005            owners.insert(prefix.clone(), owner_module_id.to_string());
2006        }
2007    }
2008
2009    /// The launch nonce most recently minted for a module's spawn, if any.
2010    #[cfg(test)]
2011    pub(crate) fn spawn_nonce(&self, module_id: &str) -> Option<String> {
2012        self.spawn_nonces
2013            .lock()
2014            .unwrap_or_else(|poisoned| poisoned.into_inner())
2015            .get(module_id)
2016            .cloned()
2017    }
2018
2019    fn apply_identity_configuration(&self, spec: &ModuleSpec) {
2020        self.set_reserved_prefixes(&spec.module_id, &spec.reserved_prefixes);
2021        let spawn_nonce = self
2022            .spawn_nonces
2023            .lock()
2024            .unwrap_or_else(|poisoned| poisoned.into_inner())
2025            .get(&spec.module_id)
2026            .cloned();
2027        let mut reserved_nonces = self
2028            .reserved_nonces
2029            .lock()
2030            .unwrap_or_else(|poisoned| poisoned.into_inner());
2031        if spec.reserved {
2032            // `None` (no spawn nonce minted) is INSERTED, not skipped: a
2033            // reserved name whose module has never spawned has no legitimate
2034            // holder, and the entry's absence is what used to leave the name
2035            // open to the first claimant.
2036            reserved_nonces.insert(spec.module_id.clone(), spawn_nonce);
2037        }
2038        drop(reserved_nonces);
2039        // A later unreserved declaration must not silently unreserve an id that
2040        // was retained after its reserved configuration was removed. The explicit
2041        // release ceremony is the only operation that retires that gate.
2042        self.removal_tombstones
2043            .lock()
2044            .unwrap_or_else(|poisoned| poisoned.into_inner())
2045            .remove(&spec.module_id);
2046    }
2047
2048    /// Whether a HELLO claiming `module_id` is authorized. An exact reserved id is
2049    /// authorized only by its expected nonce; otherwise a matching reserved prefix
2050    /// is authorized by the owner module's current spawn nonce. Non-reserved ids
2051    /// with no matching prefix are always authorized.
2052    pub fn reserved_hello_authorized(&self, module_id: &str, presented: Option<&str>) -> bool {
2053        self.reserved_hello_rejection(module_id, presented)
2054            .is_none()
2055    }
2056
2057    pub(crate) fn reserved_hello_rejection(
2058        &self,
2059        module_id: &str,
2060        presented: Option<&str>,
2061    ) -> Option<ReservedHelloRejection> {
2062        let nonces = self
2063            .reserved_nonces
2064            .lock()
2065            .unwrap_or_else(|poisoned| poisoned.into_inner());
2066        if let Some(expected) = nonces.get(module_id) {
2067            // `None` = reserved with no legitimate holder: refuse every
2068            // presentation, because no process can hold a nonce that was never
2069            // minted. Only a real minted nonce admits, in constant time.
2070            let authorized = match expected {
2071                Some(expected) => {
2072                    presented.is_some_and(|p| constant_time_eq(expected.as_bytes(), p.as_bytes()))
2073                }
2074                None => false,
2075            };
2076            if authorized {
2077                return None;
2078            }
2079            return Some(ReservedHelloRejection::Exact {
2080                module_id: module_id.to_string(),
2081            });
2082        }
2083        drop(nonces);
2084
2085        let matched_prefix = self
2086            .reserved_prefix_owners
2087            .lock()
2088            .unwrap_or_else(|poisoned| poisoned.into_inner())
2089            .iter()
2090            .filter(|(prefix, _)| module_id.starts_with(prefix.as_str()))
2091            .max_by_key(|(prefix, _)| prefix.len())
2092            .map(|(prefix, owner)| (prefix.clone(), owner.clone()));
2093        let (prefix, owner_module_id) = matched_prefix?;
2094
2095        let authorized = presented.is_some_and(|presented| {
2096            self.spawn_nonces
2097                .lock()
2098                .unwrap_or_else(|poisoned| poisoned.into_inner())
2099                .get(&owner_module_id)
2100                .is_some_and(|expected| constant_time_eq(expected.as_bytes(), presented.as_bytes()))
2101                // While the owner is being swapped, children started by
2102                // either of its two processes hold that process's nonce.
2103                || self.swap_nonce_matches(&owner_module_id, presented)
2104        });
2105        if authorized {
2106            None
2107        } else {
2108            Some(ReservedHelloRejection::Prefix {
2109                prefix,
2110                owner_module_id,
2111            })
2112        }
2113    }
2114
2115    /// Whether a consumer connection proved it came from a daemon-spawned module.
2116    ///
2117    /// Absence of an expected spawn nonce is a hard failure: consumer_identity is
2118    /// accepted only for module ids the supervisor has spawned.
2119    pub fn spawned_consumer_authorized(&self, module_id: &str, presented: &str) -> bool {
2120        if presented.is_empty() {
2121            return false;
2122        }
2123        let nonces = self
2124            .spawn_nonces
2125            .lock()
2126            .unwrap_or_else(|poisoned| poisoned.into_inner());
2127        let current = nonces
2128            .get(module_id)
2129            .is_some_and(|expected| constant_time_eq(expected.as_bytes(), presented.as_bytes()));
2130        drop(nonces);
2131        // During a swap two processes of the module are alive, and a consumer
2132        // started by either one presents that process's nonce. Accepting only
2133        // the recorded one would fail the incumbent's consumers for the whole
2134        // overlap once cutover moves the record to the candidate.
2135        current || self.swap_nonce_matches(module_id, presented)
2136    }
2137
2138    /// Whether `presented` is either nonce of an open swap for `module_id`.
2139    fn swap_nonce_matches(&self, module_id: &str, presented: &str) -> bool {
2140        let swaps = self
2141            .swaps
2142            .lock()
2143            .unwrap_or_else(|poisoned| poisoned.into_inner());
2144        swaps.get(module_id).is_some_and(|swap| {
2145            constant_time_eq(swap.candidate_nonce.as_bytes(), presented.as_bytes())
2146                || swap.incumbent_nonce.as_deref().is_some_and(|incumbent| {
2147                    constant_time_eq(incumbent.as_bytes(), presented.as_bytes())
2148                })
2149        })
2150    }
2151
2152    /// Open a swap for `module_id` with the candidate's freshly minted nonce.
2153    /// Called before the candidate process exists.
2154    pub(crate) fn open_swap(&self, module_id: &str, candidate_nonce: String) {
2155        let incumbent_nonce = self
2156            .spawn_nonces
2157            .lock()
2158            .unwrap_or_else(|poisoned| poisoned.into_inner())
2159            .get(module_id)
2160            .cloned();
2161        self.swaps
2162            .lock()
2163            .unwrap_or_else(|poisoned| poisoned.into_inner())
2164            .insert(
2165                module_id.to_string(),
2166                OpenSwap {
2167                    candidate_nonce,
2168                    incumbent_nonce,
2169                    candidate_admitted: false,
2170                },
2171            );
2172    }
2173
2174    /// Close the swap for `module_id`, releasing whichever nonce is no longer
2175    /// the module's recorded one.
2176    pub(crate) fn close_swap(&self, module_id: &str) {
2177        self.swaps
2178            .lock()
2179            .unwrap_or_else(|poisoned| poisoned.into_inner())
2180            .remove(module_id);
2181    }
2182
2183    /// Install the observer told about swap promotions, replacing any earlier
2184    /// one.
2185    pub(crate) fn set_swap_promotion_observer(
2186        &self,
2187        observer: std::sync::Weak<dyn SwapPromotionObserver>,
2188    ) {
2189        *self
2190            .promotion_observer
2191            .0
2192            .lock()
2193            .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(observer);
2194    }
2195
2196    /// Tell the installed observer, if it is still alive, that a swap promoted
2197    /// `registration`.
2198    fn notify_swap_promoted(&self, registration: &crate::registry::ModuleRegistration) {
2199        let observer = self
2200            .promotion_observer
2201            .0
2202            .lock()
2203            .unwrap_or_else(|poisoned| poisoned.into_inner())
2204            .as_ref()
2205            .and_then(std::sync::Weak::upgrade);
2206        if let Some(observer) = observer {
2207            observer.swap_promoted(registration);
2208        }
2209    }
2210
2211    /// Whether a swap is open for `module_id`.
2212    pub(crate) fn swap_open(&self, module_id: &str) -> bool {
2213        self.swaps
2214            .lock()
2215            .unwrap_or_else(|poisoned| poisoned.into_inner())
2216            .contains_key(module_id)
2217    }
2218
2219    /// Make the candidate's nonce the module's recorded spawn nonce, as a plain
2220    /// respawn would, once cutover has made the candidate the module's process.
2221    /// The swap stays open so the incumbent's nonce keeps attesting until the
2222    /// incumbent has drained and exited.
2223    fn promote_swap_nonce(&self, module_id: &str, reserved: bool) {
2224        let candidate_nonce = self
2225            .swaps
2226            .lock()
2227            .unwrap_or_else(|poisoned| poisoned.into_inner())
2228            .get(module_id)
2229            .map(|swap| swap.candidate_nonce.clone());
2230        let Some(nonce) = candidate_nonce else {
2231            return;
2232        };
2233        self.set_spawn_nonce(module_id, nonce.clone());
2234        if reserved {
2235            self.set_reserved_nonce(module_id, nonce);
2236        }
2237    }
2238
2239    /// The swap gate for a HELLO claiming `module_id`.
2240    ///
2241    /// This runs BEFORE the reserved-module gate. A reserved module's candidate
2242    /// presents the candidate nonce, which the reserved gate (holding the
2243    /// incumbent's nonce) would refuse as `reserved_module` before swap
2244    /// admission was ever reached. And it applies to unreserved ids too: for an
2245    /// unreserved id the only thing that ever stopped a second process claiming
2246    /// a live id was the `duplicate_module_id` refusal, which is exactly the
2247    /// refusal a swap lifts for its candidate.
2248    ///
2249    /// The incumbent's own nonce falls through to the ordinary gates, which
2250    /// treat it as they always have (a live incumbent is refused as a
2251    /// duplicate). Anything else while a swap is open is refused, including an
2252    /// absent nonce.
2253    pub(crate) fn swap_hello_admission(
2254        &self,
2255        module_id: &str,
2256        presented: Option<&str>,
2257    ) -> SwapHelloAdmission {
2258        let swaps = self
2259            .swaps
2260            .lock()
2261            .unwrap_or_else(|poisoned| poisoned.into_inner());
2262        let Some(swap) = swaps.get(module_id) else {
2263            return SwapHelloAdmission::NotSwapping;
2264        };
2265        let Some(presented) = presented else {
2266            return SwapHelloAdmission::Refused;
2267        };
2268        if constant_time_eq(swap.candidate_nonce.as_bytes(), presented.as_bytes()) {
2269            return if swap.candidate_admitted {
2270                SwapHelloAdmission::Refused
2271            } else {
2272                SwapHelloAdmission::Candidate
2273            };
2274        }
2275        if swap
2276            .incumbent_nonce
2277            .as_deref()
2278            .is_some_and(|incumbent| constant_time_eq(incumbent.as_bytes(), presented.as_bytes()))
2279        {
2280            return SwapHelloAdmission::NotSwapping;
2281        }
2282        SwapHelloAdmission::Refused
2283    }
2284
2285    /// Record that the swap token has registered a candidate, so it admits no
2286    /// second HELLO.
2287    pub(crate) fn mark_swap_candidate_admitted(&self, module_id: &str) {
2288        if let Some(swap) = self
2289            .swaps
2290            .lock()
2291            .unwrap_or_else(|poisoned| poisoned.into_inner())
2292            .get_mut(module_id)
2293        {
2294            swap.candidate_admitted = true;
2295        }
2296    }
2297
2298    /// Test/support lookup for the current launch nonce of a supervised spawn.
2299    pub fn spawn_launch_nonce_for(&self, module_id: &str) -> Option<String> {
2300        self.spawn_nonces
2301            .lock()
2302            .unwrap_or_else(|poisoned| poisoned.into_inner())
2303            .get(module_id)
2304            .cloned()
2305    }
2306
2307    /// Test/support lookup for the HELLO-gating nonce of a reserved module.
2308    pub fn reserved_launch_nonce_for(&self, module_id: &str) -> Option<String> {
2309        self.reserved_nonces
2310            .lock()
2311            .unwrap_or_else(|poisoned| poisoned.into_inner())
2312            .get(module_id)
2313            .cloned()
2314            .flatten()
2315    }
2316
2317    pub fn insert(&self, module: SupervisedModule) -> Option<SupervisedModule> {
2318        // Normally already marked before the process was spawned; marking here
2319        // too keeps `configured_ids` a superset of the roster for any caller
2320        // that inserts a module directly.
2321        self.mark_configured(module.module_id());
2322        let mut modules = self
2323            .modules
2324            .lock()
2325            .unwrap_or_else(|poisoned| poisoned.into_inner());
2326        modules.insert(module.module_id().to_string(), module)
2327    }
2328
2329    /// Record that the supervisor has taken on `module_id`. Called before the
2330    /// module's first process is spawned, so that by the time that process can
2331    /// register, [`Self::is_configured`] already answers true.
2332    fn mark_configured(&self, module_id: &str) {
2333        self.configured_ids
2334            .lock()
2335            .unwrap_or_else(|poisoned| poisoned.into_inner())
2336            .insert(module_id.to_string());
2337    }
2338
2339    /// Undo [`Self::mark_configured`] for a module whose first spawn failed
2340    /// before it was ever put on the roster. A module already on the roster
2341    /// keeps its mark: only [`Self::retire`] takes a rostered module off.
2342    fn unmark_configured_unless_rostered(&self, module_id: &str) {
2343        let modules = self
2344            .modules
2345            .lock()
2346            .unwrap_or_else(|poisoned| poisoned.into_inner());
2347        if !modules.contains_key(module_id) {
2348            self.configured_ids
2349                .lock()
2350                .unwrap_or_else(|poisoned| poisoned.into_inner())
2351                .remove(module_id);
2352        }
2353    }
2354
2355    /// Whether `module_id` is a module this daemon supervises: on the roster,
2356    /// or about to be (its process is being spawned right now).
2357    ///
2358    /// This, not `get(..).is_some()`, is what "the owner is configured" means
2359    /// for scopes: a supervised module's process can register and sync before
2360    /// [`Self::get`] can return it, and in that window it is still a module
2361    /// that will sync, not one that never will.
2362    pub(crate) fn is_configured(&self, module_id: &str) -> bool {
2363        self.configured_ids
2364            .lock()
2365            .unwrap_or_else(|poisoned| poisoned.into_inner())
2366            .contains(module_id)
2367    }
2368
2369    pub fn get(&self, module_id: &str) -> Option<SupervisedModule> {
2370        let modules = self
2371            .modules
2372            .lock()
2373            .unwrap_or_else(|poisoned| poisoned.into_inner());
2374        modules.get(module_id).cloned()
2375    }
2376
2377    pub(crate) fn record_late_health_answer(
2378        &self,
2379        module_id: &str,
2380        latency_ms: u64,
2381    ) -> Result<bool, SuperviseError> {
2382        let Some(module) = self.get(module_id) else {
2383            return Ok(false);
2384        };
2385        update_snapshot(&module.inner.snapshot, Some(module_id), |state| {
2386            state.health.late_answer_count = state.health.late_answer_count.saturating_add(1);
2387            state.health.last_late_answer_latency_ms = Some(latency_ms);
2388            // A late answer is an answer: the module served the probe, just past
2389            // the deadline. Leaving the miss streak in place while logging
2390            // "proves the module is alive" is how a CPU-starved module that
2391            // answers every probe a few seconds late still marches to the
2392            // threshold and gets killed — the exact kill class `NoAnswer` is
2393            // excluded from `is_proof_of_death` to prevent. Slow-but-answering
2394            // is degradation, and degradation reports; it does not restart.
2395            state.health.consecutive_failures = 0;
2396        })?;
2397        Ok(true)
2398    }
2399
2400    /// Arm the one-shot marker for the module process that this caller
2401    /// deliberately initiated severance against. Generic connection teardown
2402    /// must not call this:
2403    /// a surviving process would otherwise retain an exemption for a later
2404    /// genuine crash.
2405    pub fn record_deliberate_severance(&self, module_id: &str) -> Result<bool, SuperviseError> {
2406        let Some(module) = self.get(module_id) else {
2407            return Ok(false);
2408        };
2409        let snapshot = lock_snapshot(&module.inner.snapshot)?;
2410        let Some((pid, start_time)) = snapshot.pid.zip(snapshot.process_start_time) else {
2411            return Ok(false);
2412        };
2413        drop(snapshot);
2414        module.record_deliberate_severance(ProcessIdentity { pid, start_time })
2415    }
2416
2417    pub fn list(&self) -> Vec<SupervisedModule> {
2418        let modules = self
2419            .modules
2420            .lock()
2421            .unwrap_or_else(|poisoned| poisoned.into_inner());
2422        let mut modules = modules.values().cloned().collect::<Vec<_>>();
2423        modules.sort_by(|left, right| left.module_id().cmp(right.module_id()));
2424        modules
2425    }
2426
2427    pub(crate) fn retire(&self, module_id: &str) -> Option<SupervisedModule> {
2428        self.spawn_nonces
2429            .lock()
2430            .unwrap_or_else(|poisoned| poisoned.into_inner())
2431            .remove(module_id);
2432        self.close_swap(module_id);
2433        let mut reserved_nonces = self
2434            .reserved_nonces
2435            .lock()
2436            .unwrap_or_else(|poisoned| poisoned.into_inner());
2437        if reserved_nonces.contains_key(module_id) {
2438            // The old nonce must die with the removed process, but the exact-id
2439            // gate remains until an operator explicitly releases it.
2440            reserved_nonces.insert(module_id.to_string(), None);
2441        }
2442        drop(reserved_nonces);
2443        self.reserved_prefix_owners
2444            .lock()
2445            .unwrap_or_else(|poisoned| poisoned.into_inner())
2446            .retain(|_, owner| owner != module_id);
2447        let removed = self
2448            .modules
2449            .lock()
2450            .unwrap_or_else(|poisoned| poisoned.into_inner())
2451            .remove(module_id);
2452        self.configured_ids
2453            .lock()
2454            .unwrap_or_else(|poisoned| poisoned.into_inner())
2455            .remove(module_id);
2456        removed
2457    }
2458
2459    /// Remember a module removed by a non-preview rescan so route.open can
2460    /// distinguish that intentional removal from an unknown id.
2461    pub(crate) fn record_rescan_removal(&self, module_id: &str) {
2462        self.removal_tombstones
2463            .lock()
2464            .unwrap_or_else(|poisoned| poisoned.into_inner())
2465            .insert(module_id.to_string(), unix_ms_now());
2466    }
2467
2468    /// Return how long ago a rescan removed this module in milliseconds.
2469    pub(crate) fn removal_tombstone_age_ms(&self, module_id: &str) -> Option<u64> {
2470        self.removal_tombstones
2471            .lock()
2472            .unwrap_or_else(|poisoned| poisoned.into_inner())
2473            .get(module_id)
2474            .copied()
2475            .map(|removed_at_ms| unix_ms_now().saturating_sub(removed_at_ms))
2476    }
2477
2478    /// Retire a reserved-id gate only after its module has left supervision.
2479    ///
2480    /// A retained gate has no live nonce (`None`), so releasing any other entry
2481    /// would weaken a currently configured or otherwise active reservation.
2482    pub(crate) fn release_retained_reserved_gate(&self, module_id: &str) -> bool {
2483        if self.get(module_id).is_some() {
2484            return false;
2485        }
2486        let mut reserved_nonces = self
2487            .reserved_nonces
2488            .lock()
2489            .unwrap_or_else(|poisoned| poisoned.into_inner());
2490        if !matches!(reserved_nonces.get(module_id), Some(None)) {
2491            return false;
2492        }
2493        reserved_nonces.remove(module_id);
2494        true
2495    }
2496
2497    pub(crate) fn operation_lock(&self) -> Arc<AsyncMutex<()>> {
2498        Arc::clone(&self.operation_lock)
2499    }
2500}
2501
2502/// Process supervisor for subc-owned singleton modules.
2503#[derive(Debug, Clone)]
2504pub struct Supervisor {
2505    registry: Arc<Registry>,
2506    restart_policy: RestartPolicy,
2507    drain_timeout: Duration,
2508    connection_file_path: Option<PathBuf>,
2509    capture_logs_dir: Option<PathBuf>,
2510    forwarding: Option<Arc<ForwardingTable>>,
2511    process_liveness: Arc<SupervisorProcessLiveness>,
2512    supervisor_handle: Option<SupervisorHandle>,
2513    health: HealthConfig,
2514    daemon_start_clock: crate::clock::StartClock,
2515    terminal_journal: Option<Arc<crate::terminal_journal::TerminalJournal>>,
2516    spawn_events: SpawnEventFeed,
2517    provenance_probe: ExecutableIdentityProbe,
2518    /// Every process spawned through this supervisor (and its clones) and not
2519    /// yet reaped, so daemon shutdown can end them.
2520    child_roster: ChildRoster,
2521    #[cfg(target_os = "linux")]
2522    cgroup_placement: Option<subc_cgroup::Placement>,
2523    #[cfg(test)]
2524    test_after_first_spawn: AfterFirstSpawnHook,
2525}
2526
2527/// Test-only hook run on the path that takes on a new module, right after its
2528/// first `spawn_child` returns (the process exists and could already be
2529/// registering) and before that process is handed to the module's supervise
2530/// loop and put on the roster. Lets a test observe what a fast child would see
2531/// in that window without racing a real one.
2532#[cfg(test)]
2533#[derive(Clone, Default)]
2534struct AfterFirstSpawnHook(Option<AfterFirstSpawnFn>);
2535
2536#[cfg(test)]
2537type AfterFirstSpawnFn = Arc<dyn Fn(&str) + Send + Sync>;
2538
2539#[cfg(test)]
2540impl fmt::Debug for AfterFirstSpawnHook {
2541    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2542        f.write_str("AfterFirstSpawnHook")
2543    }
2544}
2545
2546#[cfg(test)]
2547impl AfterFirstSpawnHook {
2548    fn run(&self, module_id: &str) {
2549        if let Some(hook) = &self.0 {
2550            hook(module_id);
2551        }
2552    }
2553}
2554
2555impl Supervisor {
2556    #[cfg(test)]
2557    pub(crate) fn new_for_test(registry: Arc<Registry>, policy: RestartPolicy) -> Self {
2558        let supervisor = Self::new(registry, policy);
2559        #[cfg(target_os = "macos")]
2560        let supervisor = supervisor.with_privacy_trampoline(test_privacy_trampoline());
2561        supervisor
2562    }
2563    /// Verify the trampoline once when configured. Missing private OS support
2564    /// refuses every macOS launch by name but does not stop the daemon's control
2565    /// server. Embedders must explicitly provide a binary with the subc-os hidden
2566    /// entry point; the library must not exec an arbitrary hosting program.
2567    pub fn with_privacy_trampoline(self, path: impl Into<PathBuf>) -> Self {
2568        let path = path.into();
2569        #[cfg(target_os = "macos")]
2570        {
2571            let result = probe_privacy_trampoline(&path).map(|()| path);
2572            if let Err(cause) = &result {
2573                error!(%cause, "macOS privacy identity unavailable; supervised launches will refuse");
2574            }
2575            self.child_roster.set_privacy_trampoline(result);
2576        }
2577        #[cfg(not(target_os = "macos"))]
2578        let _ = path;
2579        self
2580    }
2581    /// The first step of an announced daemon shutdown, before the notice and
2582    /// before any connection is closed.
2583    ///
2584    /// Sets the daemon-shutdown flag first: from here on no module is
2585    /// respawned (crash restart, operator restart, or swap), and every child
2586    /// exit is recorded as `daemon_shutdown` rather than as a crash, whether
2587    /// the module exits on the EOF this shutdown gives it or is signalled by a
2588    /// service manager that kills the whole cgroup. Then writes the journal's
2589    /// shutdown marker, which records the instant and closes this daemon
2590    /// incarnation's stretch of the journal.
2591    #[cfg(unix)]
2592    pub(crate) fn begin_daemon_shutdown(&self) {
2593        self.child_roster.close();
2594        if let Some(journal) = &self.terminal_journal {
2595            journal.stamp_shutdown();
2596        }
2597    }
2598
2599    /// Announce a cut while established connections can still carry replies.
2600    /// These budgets promise notice and a bounded wait, not child completion;
2601    /// they are local policy, not an estimate of launchd's unknown kill ceiling.
2602    #[cfg(unix)]
2603    pub(crate) async fn drain_for_daemon_shutdown(&self) -> Result<(), SuperviseError> {
2604        const NOTICE_BUDGET: Duration = Duration::from_millis(500);
2605        const DRAIN_BUDGET: Duration = Duration::from_secs(2);
2606        let Some(forwarding) = &self.forwarding else {
2607            return Ok(());
2608        };
2609        let module_ids = forwarding
2610            .begin_daemon_drain()
2611            .map_err(SuperviseError::Forwarding)?;
2612        let deadline_ms =
2613            unix_ms_now().saturating_add((NOTICE_BUDGET + DRAIN_BUDGET).as_millis() as u64);
2614        let mut notices = tokio::task::JoinSet::new();
2615        let mut drains = Vec::new();
2616        for module_id in module_ids {
2617            let Some(target) = forwarding
2618                .begin_module_drain(&module_id, RouteCloseReason::Restart)
2619                .map_err(SuperviseError::Forwarding)?
2620            else {
2621                continue;
2622            };
2623            let routes = forwarding
2624                .endpoint_routes(target.endpoint)
2625                .map_err(SuperviseError::Forwarding)?;
2626            // Restart allows deployed consumers to reopen after the new daemon
2627            // appears. The wire reason stays `restart`; what tells a daemon cut
2628            // apart from a module restart afterwards is the terminal record
2629            // itself, whose disposition is `daemon_shutdown` for every exit
2630            // observed once `begin_daemon_shutdown` has run.
2631            let command = serde_json::to_vec(&ModuleControlCommand::Draining {
2632                reason: RouteCloseReason::Restart,
2633                deadline_ms,
2634            })
2635            .expect("module draining serializes");
2636            let mut recipients = vec![(target.sink.clone(), target.negotiated_ver, command)];
2637            let mut clients: Vec<(crate::forwarding::GoodbyeTarget, Vec<u16>)> = Vec::new();
2638            for route in routes {
2639                let client = route.goodbye_target;
2640                if let Some((_, channels)) = clients
2641                    .iter_mut()
2642                    .find(|(existing, _)| existing.connection_id == client.connection_id)
2643                {
2644                    channels.push(client.channel);
2645                } else {
2646                    let channel = client.channel;
2647                    clients.push((client, vec![channel]));
2648                }
2649            }
2650            for (client, mut channels) in clients {
2651                channels.sort_unstable();
2652                channels.dedup();
2653                let closing = serde_json::to_vec(&ClientControlPush::RouteClosing {
2654                    module_id: module_id.clone(),
2655                    channels,
2656                    reason: RouteCloseReason::Restart,
2657                })
2658                .expect("route closing serializes");
2659                recipients.push((client.sink, client.negotiated_ver, closing));
2660            }
2661            for (sink, version, body) in recipients {
2662                notices.spawn(async move {
2663                    let frame = Frame::build_with_version(
2664                        version,
2665                        FrameType::Push,
2666                        control_flags(),
2667                        0,
2668                        0,
2669                        0,
2670                        body,
2671                    )
2672                    .expect("bounded lifecycle notice frame builds");
2673                    sink.send_flushed(frame).await
2674                });
2675            }
2676            let gauges = declared_busy_gauges(&self.registry, &module_id)?;
2677            drains.push((module_id, target.endpoint, gauges));
2678        }
2679        // A quiet forwarding table is not proof that queued notices reached the
2680        // socket. Wait for writer flush acknowledgements before testing quiescence.
2681        let notice_deadline = Instant::now() + NOTICE_BUDGET;
2682        while let Ok(Some(result)) = timeout_at(notice_deadline, notices.join_next()).await {
2683            if !matches!(result, Ok(Ok(()))) {
2684                warn!(?result, "daemon shutdown notice delivery failed");
2685            }
2686        }
2687        notices.abort_all();
2688        let deadline = Instant::now() + DRAIN_BUDGET;
2689        let mut waits = tokio::task::JoinSet::new();
2690        for (module_id, endpoint, gauges) in drains {
2691            let forwarding = Arc::clone(forwarding);
2692            let mut runtime = self.runtime_config();
2693            runtime.health.cadence = Duration::from_millis(100);
2694            waits.spawn(async move {
2695                wait_for_forwarding_quiescence(
2696                    &forwarding,
2697                    &module_id,
2698                    &runtime,
2699                    endpoint,
2700                    deadline,
2701                    &gauges,
2702                    DrainScope::Active,
2703                )
2704                .await
2705            });
2706        }
2707        while let Ok(Some(result)) = timeout_at(deadline, waits.join_next()).await {
2708            if !matches!(result, Ok(Ok(true))) {
2709                warn!(?result, "daemon shutdown drain did not reach quiescence");
2710            }
2711        }
2712        Ok(())
2713    }
2714
2715    /// The last step of an announced daemon shutdown, after the notice and the
2716    /// drain: send every registered module a module GOODBYE, the same planned
2717    /// stop signal `ck module stop` gives, then close every connection so each
2718    /// subc module sees EOF and starts its own teardown, then end every
2719    /// supervised child that has not exited
2720    /// by its own deadline (its drain budget, capped). Modules lead their own
2721    /// process groups, so a
2722    /// service manager's group kill no longer reaches them; without this a
2723    /// child that does not stop on EOF (every `protocol: "none"` child, which
2724    /// has no connection) would outlive the daemon. Every wait is bounded (see
2725    /// `child_roster`), and `escalate` resolving (a second SIGTERM) cuts them.
2726    #[cfg(unix)]
2727    pub(crate) async fn end_children_for_daemon_shutdown(
2728        &self,
2729        already_escalated: bool,
2730        escalate: impl std::future::Future<Output = ()>,
2731    ) {
2732        tokio::pin!(escalate);
2733        let mut escalated = already_escalated;
2734        if let Some(forwarding) = &self.forwarding {
2735            let reason = CloseReason::new(
2736                "daemon_shutdown",
2737                "the daemon is exiting after its shutdown notice and drain",
2738            );
2739            if escalated {
2740                // The operator asked to stop waiting: queue the GOODBYEs but
2741                // do not wait for them to be written.
2742                send_module_goodbyes_for_daemon_shutdown(forwarding, &reason, false).await;
2743            } else {
2744                tokio::select! {
2745                    biased;
2746                    _ = escalate.as_mut() => {
2747                        info!("second SIGTERM: abandoning module GOODBYE delivery");
2748                        escalated = true;
2749                    }
2750                    _ = send_module_goodbyes_for_daemon_shutdown(forwarding, &reason, true) => {}
2751                }
2752            }
2753            let closed = forwarding.close_all_connections(&reason);
2754            debug!(closed, "closed established connections for daemon shutdown");
2755        }
2756        // If the second SIGTERM arrived during GOODBYE delivery, `escalate` has
2757        // already completed and must not be polled again; the child shutdown
2758        // wait is told it is escalated and gets a future that never fires.
2759        let escalated_here = escalated && !already_escalated;
2760        let remaining_escalate = async move {
2761            if escalated_here {
2762                std::future::pending::<()>().await;
2763            } else {
2764                escalate.await;
2765            }
2766        };
2767        crate::child_roster::end_children_for_daemon_shutdown(
2768            &self.child_roster,
2769            escalated,
2770            remaining_escalate,
2771        )
2772        .await;
2773    }
2774
2775    pub fn new(registry: Arc<Registry>, restart_policy: RestartPolicy) -> Self {
2776        Self {
2777            registry,
2778            restart_policy,
2779            drain_timeout: DEFAULT_DRAIN_TIMEOUT,
2780            connection_file_path: None,
2781            capture_logs_dir: None,
2782            forwarding: None,
2783            process_liveness: Arc::new(SupervisorProcessLiveness::default()),
2784            supervisor_handle: None,
2785            health: HealthConfig::default(),
2786            daemon_start_clock: crate::clock::StartClock::capture(),
2787            terminal_journal: None,
2788            spawn_events: SpawnEventFeed::default(),
2789            provenance_probe: ExecutableIdentityProbe::default(),
2790            child_roster: ChildRoster::default(),
2791            #[cfg(target_os = "linux")]
2792            cgroup_placement: None,
2793            #[cfg(test)]
2794            test_after_first_spawn: AfterFirstSpawnHook::default(),
2795        }
2796    }
2797
2798    pub fn with_drain_timeout(mut self, drain_timeout: Duration) -> Self {
2799        self.drain_timeout = drain_timeout;
2800        self
2801    }
2802
2803    pub fn with_process_liveness(
2804        mut self,
2805        process_liveness: Arc<SupervisorProcessLiveness>,
2806    ) -> Self {
2807        self.process_liveness = process_liveness;
2808        self
2809    }
2810
2811    pub fn with_connection_file_path(mut self, connection_file_path: impl Into<PathBuf>) -> Self {
2812        self.connection_file_path = Some(connection_file_path.into());
2813        self
2814    }
2815
2816    /// Enables daemon-owned capture files for supervised stdout and stderr.
2817    pub fn with_capture_logs_dir(mut self, logs_dir: impl Into<PathBuf>) -> Self {
2818        self.capture_logs_dir = Some(logs_dir.into());
2819        self
2820    }
2821
2822    /// Names this daemon lifetime in spawn events, independently of whether a
2823    /// terminal journal is configured.
2824    pub fn with_daemon_incarnation(self, daemon_incarnation: String) -> Self {
2825        // A millisecond start stamp can repeat after clock rollback or a rapid
2826        // restart. Use the connection file's random daemon_id instead: it already
2827        // identifies this daemon lifetime independently of the wall clock.
2828        self.spawn_events.configure_incarnation(daemon_incarnation);
2829        self
2830    }
2831
2832    /// Enables best-effort history shared by every supervised module. Without
2833    /// it, terminal history is kept only in each module's in-memory ring.
2834    pub fn with_terminal_journal(self, path: PathBuf, daemon_incarnation: String) -> Self {
2835        let mut this = self.with_daemon_incarnation(daemon_incarnation.clone());
2836        this.terminal_journal = Some(Arc::new(crate::terminal_journal::TerminalJournal::open(
2837            path,
2838            daemon_incarnation,
2839        )));
2840        this
2841    }
2842
2843    pub fn with_forwarding(mut self, forwarding: Arc<ForwardingTable>) -> Self {
2844        self.forwarding = Some(forwarding);
2845        self
2846    }
2847
2848    pub fn with_handle(mut self, supervisor_handle: SupervisorHandle) -> Self {
2849        self.spawn_events = supervisor_handle.spawn_events.clone();
2850        self.supervisor_handle = Some(supervisor_handle);
2851        self
2852    }
2853
2854    pub fn with_health_config(mut self, health: HealthConfig) -> Self {
2855        self.health = health;
2856        self
2857    }
2858
2859    /// Keeps a record of every live child at `path`, rewritten on each spawn and
2860    /// reap, for the orphan sweep a later daemon runs at boot. Without it no
2861    /// record is kept.
2862    pub fn with_live_children_record(self, path: impl Into<PathBuf>) -> Self {
2863        self.child_roster.record_to(path.into());
2864        self
2865    }
2866
2867    #[cfg(target_os = "linux")]
2868    pub fn with_cgroup_placement(
2869        mut self,
2870        cgroup_placement: Option<subc_cgroup::Placement>,
2871    ) -> Self {
2872        self.cgroup_placement = cgroup_placement;
2873        self
2874    }
2875
2876    /// Spawn `spec.program` and start monitoring it.
2877    ///
2878    /// The child is expected to parse `--subc <connection-file-path>`, read the
2879    /// TCP+key connection file, authenticate to the already-running listener, and
2880    /// register with channel-0 `HELLO` using `spec.module_id` as its manifest id.
2881    pub fn spawn(&self, spec: ModuleSpec) -> Result<SupervisedModule, SuperviseError> {
2882        validate_spec(&spec)?;
2883        self.establish_identity(&spec);
2884
2885        let runtime = self.runtime_config();
2886        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
2887        let spawned = spawn_child(
2888            &spec,
2889            runtime.connection_file_path.as_deref(),
2890            self.supervisor_handle.as_ref(),
2891            &runtime.stderr_ring,
2892            runtime.capture_logs_dir.as_deref(),
2893            &runtime.child_roster,
2894            #[cfg(target_os = "linux")]
2895            runtime.cgroup_placement.as_ref(),
2896        );
2897        #[cfg(test)]
2898        self.test_after_first_spawn.run(&spec.module_id);
2899        let child = match spawned {
2900            Ok(child) => child,
2901            Err(err) => {
2902                // Unlike the configured paths, a failed `spawn` leaves nothing
2903                // on the roster, so the module must not stay marked configured.
2904                self.abandon_unrostered(&spec.module_id);
2905                return Err(err);
2906            }
2907        };
2908        self.mark_first_process_running(&spec, &runtime, &snapshot, &child)?;
2909
2910        Ok(self.supervised_module(spec, runtime, snapshot, Some(child)))
2911    }
2912
2913    /// Make `spec`'s module count as configured, with its identity gates
2914    /// (reserved id, reserved prefixes) in place, BEFORE any process of it
2915    /// exists.
2916    ///
2917    /// Every path that takes on a new module calls this before `spawn_child`.
2918    /// The order is the point: the child can connect, register, sync its
2919    /// scopes and ask about them as soon as it is spawned, and the module is
2920    /// only put on the roster after `spawn_child` returns. Were the mark set
2921    /// with the roster entry, a fast child would see its own owner reported
2922    /// as not configured, and a scoped `route.open` in that window would be
2923    /// refused as terminal `scope_not_live` ("will never sync") instead of
2924    /// retryable `scope_not_synced`.
2925    fn establish_identity(&self, spec: &ModuleSpec) {
2926        if let Some(supervisor_handle) = &self.supervisor_handle {
2927            supervisor_handle.apply_identity_configuration(spec);
2928            supervisor_handle.mark_configured(&spec.module_id);
2929        }
2930    }
2931
2932    /// Take back [`Self::establish_identity`]'s configured mark when the
2933    /// module will not be put on the roster after all.
2934    fn abandon_unrostered(&self, module_id: &str) {
2935        if let Some(supervisor_handle) = &self.supervisor_handle {
2936            supervisor_handle.unmark_configured_unless_rostered(module_id);
2937        }
2938    }
2939
2940    /// Record a freshly spawned first process as running. On failure the
2941    /// module never reaches the roster, so its configured mark is taken back.
2942    fn mark_first_process_running(
2943        &self,
2944        spec: &ModuleSpec,
2945        runtime: &SupervisorRuntimeConfig,
2946        snapshot: &SharedSnapshot,
2947        child: &SupervisedChild,
2948    ) -> Result<(), SuperviseError> {
2949        if let Err(err) = set_running(snapshot, child, &spec.module_id, &runtime.spawn_events) {
2950            self.abandon_unrostered(&spec.module_id);
2951            return Err(err);
2952        }
2953        self.process_liveness
2954            .track(spec.module_id.clone(), Arc::clone(snapshot));
2955        Ok(())
2956    }
2957
2958    /// Start supervising a module declared in daemon configuration.
2959    ///
2960    /// Unlike [`Self::spawn`], this records disabled modules and immediate spawn
2961    /// failures in the supervisor handle so operator-facing `supervisor.list`
2962    /// reflects every configured module while daemon startup continues.
2963    pub fn supervise_configured(
2964        &self,
2965        spec: ModuleSpec,
2966        enabled: bool,
2967    ) -> Result<SupervisedModule, SuperviseError> {
2968        validate_spec(&spec)?;
2969        self.establish_identity(&spec);
2970
2971        let runtime = self.runtime_config();
2972        if !enabled {
2973            let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::disabled()));
2974            return Ok(self.supervised_module(spec, runtime, snapshot, None));
2975        }
2976
2977        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
2978        let spawned = spawn_child(
2979            &spec,
2980            runtime.connection_file_path.as_deref(),
2981            self.supervisor_handle.as_ref(),
2982            &runtime.stderr_ring,
2983            runtime.capture_logs_dir.as_deref(),
2984            &runtime.child_roster,
2985            #[cfg(target_os = "linux")]
2986            runtime.cgroup_placement.as_ref(),
2987        );
2988        #[cfg(test)]
2989        self.test_after_first_spawn.run(&spec.module_id);
2990        match spawned {
2991            Ok(child) => {
2992                self.mark_first_process_running(&spec, &runtime, &snapshot, &child)?;
2993                Ok(self.supervised_module(spec, runtime, snapshot, Some(child)))
2994            }
2995            Err(err) => {
2996                error!(
2997                    module_id = %spec.module_id,
2998                    program = %spec.program.display(),
2999                    error = %err,
3000                    "configured module failed to spawn; marking failed and continuing"
3001                );
3002                let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::failed()));
3003                Ok(self.supervised_module(spec, runtime, snapshot, None))
3004            }
3005        }
3006    }
3007
3008    /// Supervise a configured module with its own health, drain, and crash
3009    /// budget. The restart policy is per-module because the config file is:
3010    /// `modules.<id>.restart` resolves to a full policy at parse time, and a
3011    /// module that is expensive to restart should not be forced onto the same
3012    /// budget as one that is cheap.
3013    pub fn supervise_configured_with_health(
3014        &self,
3015        spec: ModuleSpec,
3016        enabled: bool,
3017        health: HealthConfig,
3018        drain_timeout_ms: Option<u64>,
3019        restart_policy: RestartPolicy,
3020    ) -> Result<SupervisedModule, SuperviseError> {
3021        validate_spec(&spec)?;
3022        self.establish_identity(&spec);
3023
3024        let mut runtime = self.runtime_config();
3025        runtime.health = health.clone();
3026        runtime.restart_policy = restart_policy;
3027        if let Some(ms) = drain_timeout_ms {
3028            runtime.drain_timeout = Duration::from_millis(ms);
3029            *runtime
3030                .effective_drain_timeout
3031                .lock()
3032                .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.drain_timeout;
3033        }
3034        if !enabled {
3035            let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::disabled()));
3036            return Ok(self.supervised_module(spec, runtime, snapshot, None));
3037        }
3038
3039        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
3040        let spawned = spawn_child(
3041            &spec,
3042            runtime.connection_file_path.as_deref(),
3043            self.supervisor_handle.as_ref(),
3044            &runtime.stderr_ring,
3045            runtime.capture_logs_dir.as_deref(),
3046            &runtime.child_roster,
3047            #[cfg(target_os = "linux")]
3048            runtime.cgroup_placement.as_ref(),
3049        );
3050        #[cfg(test)]
3051        self.test_after_first_spawn.run(&spec.module_id);
3052        match spawned {
3053            Ok(child) => {
3054                self.mark_first_process_running(&spec, &runtime, &snapshot, &child)?;
3055                Ok(self.supervised_module(spec, runtime, snapshot, Some(child)))
3056            }
3057            Err(err) => {
3058                if health.critical {
3059                    error!(
3060                        module_id = %spec.module_id,
3061                        program = %spec.program.display(),
3062                        error = %err,
3063                        "critical configured module failed to spawn; marking failed and alerting"
3064                    );
3065                } else {
3066                    error!(
3067                        module_id = %spec.module_id,
3068                        program = %spec.program.display(),
3069                        error = %err,
3070                        "configured module failed to spawn; marking failed and continuing"
3071                    );
3072                }
3073                let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::failed()));
3074                Ok(self.supervised_module(spec, runtime, snapshot, None))
3075            }
3076        }
3077    }
3078
3079    fn runtime_config(&self) -> SupervisorRuntimeConfig {
3080        let effective_drain_timeout = Arc::new(Mutex::new(self.drain_timeout));
3081        SupervisorRuntimeConfig {
3082            scheduled_respawn: Arc::default(),
3083            deferred_reload_reply: Arc::default(),
3084            restart_policy: self.restart_policy,
3085            drain_timeout: self.drain_timeout,
3086            // Shared with this module's roster copy: daemon shutdown waits on
3087            // each child for the module's own drain budget, as resolved now.
3088            child_roster: self
3089                .child_roster
3090                .for_module(Arc::clone(&effective_drain_timeout)),
3091            effective_drain_timeout,
3092            default_drain_timeout: self.drain_timeout,
3093            health: self.health.clone(),
3094            connection_file_path: self.connection_file_path.clone(),
3095            capture_logs_dir: self.capture_logs_dir.clone(),
3096            forwarding: self.forwarding.clone(),
3097            supervisor_handle: self.supervisor_handle.clone(),
3098            stderr_ring: Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default()))),
3099            terminal_ring: Arc::new(Mutex::new(
3100                TerminalRing::new(
3101                    TerminalRingConfig::default(),
3102                    self.daemon_start_clock.started_at_ms(),
3103                )
3104                .with_start_clock(self.daemon_start_clock)
3105                .with_journal(self.terminal_journal.clone())
3106                .with_daemon_shutdown(self.child_roster.shutdown_flag()),
3107            )),
3108            spawn_events: self.spawn_events.clone(),
3109            #[cfg(target_os = "linux")]
3110            cgroup_placement: self.cgroup_placement.clone(),
3111            #[cfg(test)]
3112            test_seed_stale_facts_before_enable_spawn: false,
3113            #[cfg(test)]
3114            test_reload_exit_record_gate: None,
3115        }
3116    }
3117
3118    fn supervised_module(
3119        &self,
3120        spec: ModuleSpec,
3121        runtime: SupervisorRuntimeConfig,
3122        snapshot: SharedSnapshot,
3123        child: Option<SupervisedChild>,
3124    ) -> SupervisedModule {
3125        let configuration = Arc::new(Mutex::new(SupervisedConfiguration {
3126            spec: spec.clone(),
3127            health: runtime.health.clone(),
3128        }));
3129        let stderr_ring = Arc::clone(&runtime.stderr_ring);
3130        let terminal_ring = Arc::clone(&runtime.terminal_ring);
3131        // The module's OWN policy, which may be its per-module config rather than
3132        // the supervisor-wide one; status must report the budget the supervise
3133        // loop actually enforces.
3134        let restart_policy = runtime.restart_policy;
3135        let effective_drain_timeout = Arc::clone(&runtime.effective_drain_timeout);
3136        let (tx, rx) = mpsc::channel(4);
3137        let monitor = tokio::spawn(supervise_loop(
3138            spec.clone(),
3139            runtime,
3140            Arc::clone(&self.registry),
3141            Arc::clone(&self.process_liveness),
3142            Arc::clone(&snapshot),
3143            child,
3144            rx,
3145        ));
3146
3147        let module_id = spec.module_id.clone();
3148        let module = SupervisedModule {
3149            inner: Arc::new(SupervisedModuleInner {
3150                module_id: module_id.clone(),
3151                registry: Arc::clone(&self.registry),
3152                snapshot,
3153                configuration,
3154                stderr_ring,
3155                terminal_ring,
3156                commands: tx,
3157                monitor: Mutex::new(Some(monitor)),
3158                restart_policy,
3159                effective_drain_timeout,
3160                provenance_probe: self.provenance_probe.clone(),
3161            }),
3162        };
3163        // The identity gates and the configured mark were set by
3164        // `establish_identity` before any process was spawned; only the roster
3165        // entry waits for the module handle, which needs the spawned child.
3166        if let Some(supervisor_handle) = &self.supervisor_handle {
3167            supervisor_handle.insert(module.clone());
3168        }
3169        module
3170    }
3171}
3172
3173impl Default for Supervisor {
3174    fn default() -> Self {
3175        Self::new(Arc::new(Registry::default()), RestartPolicy::default())
3176    }
3177}
3178
3179/// Handle to one supervised child process.
3180#[derive(Clone)]
3181pub struct SupervisedModule {
3182    inner: Arc<SupervisedModuleInner>,
3183}
3184
3185struct SupervisedModuleInner {
3186    module_id: String,
3187    registry: Arc<Registry>,
3188    snapshot: SharedSnapshot,
3189    configuration: Arc<Mutex<SupervisedConfiguration>>,
3190    stderr_ring: Arc<Mutex<StderrRing>>,
3191    terminal_ring: Arc<Mutex<TerminalRing>>,
3192    commands: mpsc::Sender<SupervisorCommand>,
3193    monitor: Mutex<Option<JoinHandle<()>>>,
3194    /// Copied from the supervisor's runtime config at spawn so `status()` can
3195    /// report the restart budget without reaching back into the supervisor. The
3196    /// policy is fixed for the process's lifetime, so a copy cannot drift.
3197    restart_policy: RestartPolicy,
3198    effective_drain_timeout: Arc<Mutex<Duration>>,
3199    provenance_probe: ExecutableIdentityProbe,
3200}
3201
3202impl fmt::Debug for SupervisedModule {
3203    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3204        f.debug_struct("SupervisedModule")
3205            .field("module_id", &self.inner.module_id)
3206            .field("status", &self.status())
3207            .finish_non_exhaustive()
3208    }
3209}
3210
3211impl SupervisedModule {
3212    pub fn module_id(&self) -> &str {
3213        &self.inner.module_id
3214    }
3215
3216    /// Test-only: put one probe miss on the streak, the way
3217    /// `handle_health_probe_failure` does, so tests can assert what a later
3218    /// event does to the streak without driving the whole probe loop.
3219    #[cfg(test)]
3220    pub(crate) fn record_health_probe_failure_for_test(
3221        &self,
3222        detail: &str,
3223    ) -> Result<(), SuperviseError> {
3224        update_snapshot(&self.inner.snapshot, Some(&self.inner.module_id), |state| {
3225            state.health.consecutive_failures = state.health.consecutive_failures.saturating_add(1);
3226            state.health.detail = Some(detail.to_string());
3227        })
3228    }
3229
3230    pub fn state(&self) -> Result<ModuleState, SuperviseError> {
3231        Ok(lock_snapshot(&self.inner.snapshot)?.state)
3232    }
3233
3234    pub(crate) fn registration_end_reason(
3235        &self,
3236    ) -> Result<Option<RegistrationEndReason>, SuperviseError> {
3237        let snapshot = lock_snapshot(&self.inner.snapshot)?;
3238        Ok(match snapshot.state {
3239            ModuleState::Draining if snapshot.draining_to_replace => {
3240                Some(RegistrationEndReason::SupervisorRestart)
3241            }
3242            ModuleState::Draining => Some(RegistrationEndReason::SupervisorStop),
3243            ModuleState::Restarting if snapshot.draining_to_replace => {
3244                Some(RegistrationEndReason::SupervisorRestart)
3245            }
3246            _ => None,
3247        })
3248    }
3249
3250    /// The module's retained stderr, newest lines last.
3251    ///
3252    /// Deliberately NOT on [`Self::status`]: a bounded tail is kilobytes per
3253    /// module, `supervisor.list` renders every module, and putting it in the
3254    /// shared snapshot would make each status read carry a payload almost nobody
3255    /// asked for. Callers that want the text ask for it.
3256    pub fn stderr_tail(
3257        &self,
3258        max_lines: Option<usize>,
3259        max_bytes: Option<usize>,
3260    ) -> StderrTailSnapshot {
3261        self.inner
3262            .stderr_ring
3263            .lock()
3264            .unwrap_or_else(|poisoned| poisoned.into_inner())
3265            .snapshot(max_lines, max_bytes)
3266    }
3267
3268    /// The module's bounded terminal history, oldest retained exit first.
3269    ///
3270    /// The daemon-start stamp distinguishes a quiet supervisor from a replacement
3271    /// daemon whose in-memory history was necessarily reset.
3272    pub fn terminal_history(&self) -> TerminalHistorySnapshot {
3273        self.inner
3274            .terminal_ring
3275            .lock()
3276            .unwrap_or_else(|poisoned| poisoned.into_inner())
3277            .snapshot()
3278    }
3279
3280    /// Retained observations from the current ring and all journal generations.
3281    ///
3282    /// Blocking: this reads the journal files. Async callers use
3283    /// [`Self::read_durable_terminal_history`].
3284    pub fn durable_terminal_history(&self) -> subc_control::TerminalHistory {
3285        durable_terminal_history_of(&self.inner.terminal_ring, &self.inner.module_id)
3286    }
3287
3288    /// [`Self::durable_terminal_history`] on a blocking thread, so the journal
3289    /// read (up to every retained generation) never occupies a runtime worker.
3290    /// Fails only if the blocking task could not finish (runtime shutdown or a
3291    /// panic in the read).
3292    pub(crate) async fn read_durable_terminal_history(
3293        &self,
3294    ) -> Result<subc_control::TerminalHistory, tokio::task::JoinError> {
3295        let terminal_ring = Arc::clone(&self.inner.terminal_ring);
3296        let module_id = self.inner.module_id.clone();
3297        tokio::task::spawn_blocking(move || durable_terminal_history_of(&terminal_ring, &module_id))
3298            .await
3299    }
3300
3301    pub fn status(&self) -> Result<ModuleStatus, SuperviseError> {
3302        self.status_with_snapshot_lock(&self.inner.snapshot, None)
3303            .map(|(status, _)| status)
3304    }
3305
3306    pub(crate) fn record_deliberate_severance(
3307        &self,
3308        identity: ProcessIdentity,
3309    ) -> Result<bool, SuperviseError> {
3310        let mut snapshot = lock_snapshot(&self.inner.snapshot)?;
3311        if snapshot.pid != Some(identity.pid)
3312            || snapshot.process_start_time != Some(identity.start_time)
3313        {
3314            return Ok(false);
3315        }
3316        snapshot.deliberate_severance = Some(identity);
3317        Ok(true)
3318    }
3319
3320    /// Read status for a channel-0 renderer and report a contended snapshot lock.
3321    ///
3322    /// Internal supervision callers use [`Self::status`] so writer-side machinery
3323    /// does not produce reader-observability logs.
3324    pub(crate) fn status_for_control(
3325        &self,
3326        caller: &'static str,
3327    ) -> Result<ModuleStatus, SuperviseError> {
3328        self.status_with_snapshot_lock(&self.inner.snapshot, Some(caller))
3329            .map(|(status, _)| status)
3330    }
3331
3332    fn status_with_snapshot_lock(
3333        &self,
3334        snapshot: &SharedSnapshot,
3335        caller: Option<&'static str>,
3336    ) -> Result<(ModuleStatus, Option<SpawnedFileIdentity>), SuperviseError> {
3337        let mut guard = match caller {
3338            Some(caller) => lock_snapshot_for_control(snapshot, &self.inner.module_id, caller)?,
3339            None => lock_snapshot(snapshot)?,
3340        };
3341        // Read the budget through the pruning path so a reader sees the same
3342        // in-window count the restart decision would use, not a stale total.
3343        let restart_count =
3344            guard.crash_restarts_in_window(self.inner.restart_policy.window, Instant::now());
3345        let snapshot = guard.clone();
3346        drop(guard);
3347        let drain_timeout = *self.inner.effective_drain_timeout.lock().map_err(|_| {
3348            SuperviseError::StatePoisoned {
3349                module_id: Some(self.inner.module_id.clone()),
3350            }
3351        })?;
3352        let registration_active = self
3353            .inner
3354            .registry
3355            .get_module(&self.inner.module_id)
3356            .map_err(SuperviseError::Registry)?
3357            .is_some();
3358        let protocol = snapshot
3359            .spawned_protocol
3360            .unwrap_or(self.declared_protocol()?);
3361        let running_process =
3362            snapshot.enabled && snapshot.state == ModuleState::Running && snapshot.process_alive;
3363        // Registration is the difference between the two protocols and the only
3364        // one: a subc module that has not registered cannot serve a request even
3365        // though its process is up, and a `none` module never registers at all,
3366        // so requiring it there would pin `live` to false for the whole life of
3367        // a perfectly healthy process.
3368        let live = match protocol {
3369            ModuleProtocol::Subc => running_process && registration_active,
3370            ModuleProtocol::None => running_process,
3371        };
3372
3373        Ok((
3374            ModuleStatus {
3375                module_id: self.inner.module_id.clone(),
3376                launch_nonce_source: observed_launch_nonce_source(&snapshot),
3377                state: snapshot.state,
3378                enabled: snapshot.enabled,
3379                process_alive: snapshot.process_alive,
3380                registration_active,
3381                protocol,
3382                live,
3383                restart_count,
3384                lifetime_restarts: snapshot.lifetime_restarts,
3385                spawn_generation: snapshot.spawn_generation,
3386                max_restarts: self.inner.restart_policy.max_restarts,
3387                restart_window: self.inner.restart_policy.window,
3388                drain_timeout,
3389                restart_backoff: self.inner.restart_policy.backoff,
3390                restart_max_backoff: self.inner.restart_policy.max_backoff,
3391                pid: snapshot.reported_pid(),
3392                spawned_at_ms: snapshot.spawned_at_ms,
3393                spawned_from: snapshot.spawned_from,
3394                process_start_time: snapshot.process_start_time,
3395                last_exit: snapshot.last_exit,
3396                health: snapshot.health,
3397            },
3398            snapshot.spawned_file_identity.clone(),
3399        ))
3400    }
3401
3402    #[cfg(test)]
3403    pub(crate) fn hold_snapshot_for_test(
3404        &self,
3405        acquired: std::sync::mpsc::Sender<()>,
3406        hold: Duration,
3407    ) -> std::thread::JoinHandle<()> {
3408        let snapshot = Arc::clone(&self.inner.snapshot);
3409        std::thread::spawn(move || {
3410            let _guard = snapshot.lock().expect("test snapshot lock is not poisoned");
3411            acquired
3412                .send(())
3413                .expect("test receiver waits for snapshot lock");
3414            std::thread::sleep(hold);
3415        })
3416    }
3417
3418    /// The status and the running-image check for `supervisor.provenance`,
3419    /// taken from one status read. The exec acknowledgement can land between
3420    /// two separate reads, and the reply would then pair "no pid yet" with an
3421    /// image observed after the module started, which describes no single
3422    /// moment.
3423    pub(crate) async fn status_and_running_image_agreement(
3424        &self,
3425    ) -> Result<(ModuleStatus, subc_control::RunningImageAgreement), SuperviseError> {
3426        let (status, identity) = self.status_with_snapshot_lock(&self.inner.snapshot, None)?;
3427        let image = self
3428            .inner
3429            .provenance_probe
3430            .observe(
3431                status.pid,
3432                status.spawned_from.as_deref(),
3433                identity,
3434                status.process_start_time,
3435            )
3436            .await;
3437        Ok((status, image))
3438    }
3439
3440    pub(crate) async fn running_image_agreement(&self) -> subc_control::RunningImageAgreement {
3441        let snapshot = match lock_snapshot(&self.inner.snapshot) {
3442            Ok(snapshot) => snapshot.clone(),
3443            Err(_) => {
3444                return subc_control::RunningImageAgreement::Unavailable {
3445                    reason: subc_control::RunningImageUnavailableReason::NotRunning,
3446                };
3447            }
3448        };
3449        self.inner
3450            .provenance_probe
3451            .observe(
3452                snapshot.reported_pid(),
3453                snapshot.spawned_from.as_deref(),
3454                snapshot.spawned_file_identity.clone(),
3455                snapshot.process_start_time,
3456            )
3457            .await
3458    }
3459
3460    /// Memory and CPU time of the module's current process, read now. Only the
3461    /// process the supervisor spawned is read, not processes it has started.
3462    pub(crate) fn child_resource_usage(&self) -> subc_control::ChildResourceUsage {
3463        #[cfg(windows)]
3464        {
3465            let image = lock_snapshot(&self.inner.snapshot)
3466                .ok()
3467                .and_then(|snapshot| {
3468                    snapshot
3469                        .spawned_file_identity
3470                        .as_ref()
3471                        .and_then(|identity| identity.windows_image.clone())
3472                });
3473            match image {
3474                Some(image) => {
3475                    crate::child_resources::read_windows_handle(image.process(), image.start_time())
3476                }
3477                None => subc_control::ChildResourceUsage::Unavailable {
3478                    reason: subc_control::ChildResourceUnavailableReason::NotRunning,
3479                },
3480            }
3481        }
3482        #[cfg(not(windows))]
3483        {
3484            let (pid, start_time) = match lock_snapshot(&self.inner.snapshot) {
3485                Ok(snapshot) => (snapshot.reported_pid(), snapshot.process_start_time),
3486                Err(_) => {
3487                    return subc_control::ChildResourceUsage::Unavailable {
3488                        reason: subc_control::ChildResourceUnavailableReason::Unreadable,
3489                    }
3490                }
3491            };
3492            crate::child_resources::read(pid, start_time)
3493        }
3494    }
3495
3496    pub(crate) fn will_recover_after_connection_loss(&self) -> Result<bool, SuperviseError> {
3497        let mut snapshot = lock_snapshot(&self.inner.snapshot)?;
3498        Ok(match snapshot.state {
3499            ModuleState::Restarting => true,
3500            ModuleState::Failed | ModuleState::Disabled => false,
3501            _ => daemon_will_restart(&mut snapshot, &self.inner.restart_policy, Instant::now()),
3502        })
3503    }
3504
3505    #[cfg(test)]
3506    pub(crate) fn is_warming(&self) -> Result<bool, SuperviseError> {
3507        self.is_warming_with_snapshot_lock(None)
3508    }
3509
3510    pub(crate) fn is_warming_for_control(
3511        &self,
3512        caller: &'static str,
3513    ) -> Result<bool, SuperviseError> {
3514        self.is_warming_with_snapshot_lock(Some(caller))
3515    }
3516
3517    fn is_warming_with_snapshot_lock(
3518        &self,
3519        caller: Option<&'static str>,
3520    ) -> Result<bool, SuperviseError> {
3521        let snapshot = match caller {
3522            Some(caller) => {
3523                lock_snapshot_for_control(&self.inner.snapshot, &self.inner.module_id, caller)?
3524            }
3525            None => lock_snapshot(&self.inner.snapshot)?,
3526        }
3527        .clone();
3528        Ok(matches!(
3529            snapshot.state,
3530            ModuleState::Starting | ModuleState::Running | ModuleState::Restarting
3531        ))
3532    }
3533
3534    /// Drain the module and stop monitoring it.
3535    pub async fn drain(&self) -> Result<(), SuperviseError> {
3536        self.stop().await
3537    }
3538
3539    pub(crate) async fn retire(&self) -> Result<(), SuperviseError> {
3540        match self.state()? {
3541            ModuleState::Stopped | ModuleState::Failed => return Ok(()),
3542            ModuleState::Starting
3543            | ModuleState::Running
3544            | ModuleState::Unresponsive
3545            | ModuleState::Restarting
3546            | ModuleState::Draining
3547            | ModuleState::Disabled => {}
3548        }
3549
3550        let (reply_tx, reply_rx) = oneshot::channel();
3551        self.inner
3552            .commands
3553            .send(SupervisorCommand::Retire { reply: reply_tx })
3554            .await
3555            .map_err(|_| SuperviseError::CommandClosed {
3556                module_id: self.inner.module_id.clone(),
3557            })?;
3558        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3559            module_id: self.inner.module_id.clone(),
3560        })?
3561    }
3562
3563    pub async fn stop(&self) -> Result<(), SuperviseError> {
3564        match self.state()? {
3565            ModuleState::Stopped | ModuleState::Failed => return Ok(()),
3566            ModuleState::Starting
3567            | ModuleState::Running
3568            | ModuleState::Unresponsive
3569            | ModuleState::Restarting
3570            | ModuleState::Draining
3571            | ModuleState::Disabled => {}
3572        }
3573
3574        let (reply_tx, reply_rx) = oneshot::channel();
3575        self.inner
3576            .commands
3577            .send(SupervisorCommand::Drain { reply: reply_tx })
3578            .await
3579            .map_err(|_| SuperviseError::CommandClosed {
3580                module_id: self.inner.module_id.clone(),
3581            })?;
3582        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3583            module_id: self.inner.module_id.clone(),
3584        })?
3585    }
3586
3587    pub async fn restart(&self, drain_timeout_ms: Option<u64>) -> Result<(), SuperviseError> {
3588        let received_at_generation = lock_snapshot(&self.inner.snapshot)?.spawn_generation;
3589        let (reply_tx, reply_rx) = oneshot::channel();
3590        self.inner
3591            .commands
3592            .send(SupervisorCommand::Restart {
3593                drain_timeout_ms,
3594                received_at_generation,
3595                queued_at: Instant::now(),
3596                reply: reply_tx,
3597            })
3598            .await
3599            .map_err(|_| SuperviseError::CommandClosed {
3600                module_id: self.inner.module_id.clone(),
3601            })?;
3602        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3603            module_id: self.inner.module_id.clone(),
3604        })?
3605    }
3606
3607    /// Blue/green restart: see [`SupervisorCommand::Swap`] and the
3608    /// `supervisor_swap` module. Returns once the swap has cut over (the old
3609    /// process then drains in the background of the supervise loop) or has
3610    /// failed, leaving the old process serving.
3611    pub async fn swap(&self, ready_timeout: Option<Duration>) -> Result<(), SuperviseError> {
3612        let (reply_tx, reply_rx) = oneshot::channel();
3613        self.inner
3614            .commands
3615            .send(SupervisorCommand::Swap {
3616                ready_timeout,
3617                reply: reply_tx,
3618            })
3619            .await
3620            .map_err(|_| SuperviseError::CommandClosed {
3621                module_id: self.inner.module_id.clone(),
3622            })?;
3623        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3624            module_id: self.inner.module_id.clone(),
3625        })?
3626    }
3627
3628    pub async fn reload(&self) -> Result<(), SuperviseError> {
3629        let (reply_tx, reply_rx) = oneshot::channel();
3630        self.inner
3631            .commands
3632            .send(SupervisorCommand::Reload { reply: reply_tx })
3633            .await
3634            .map_err(|_| SuperviseError::CommandClosed {
3635                module_id: self.inner.module_id.clone(),
3636            })?;
3637        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3638            module_id: self.inner.module_id.clone(),
3639        })?
3640    }
3641
3642    pub async fn set_enabled(&self, enabled: bool) -> Result<bool, SuperviseError> {
3643        let (reply_tx, reply_rx) = oneshot::channel();
3644        self.inner
3645            .commands
3646            .send(SupervisorCommand::SetEnabled {
3647                enabled,
3648                reply: reply_tx,
3649            })
3650            .await
3651            .map_err(|_| SuperviseError::CommandClosed {
3652                module_id: self.inner.module_id.clone(),
3653            })?;
3654        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3655            module_id: self.inner.module_id.clone(),
3656        })?
3657    }
3658
3659    /// The current process's protocol, or the configured protocol when down.
3660    /// A rescan stores the next launch spec without changing how an existing
3661    /// process registers, serves routes, is probed, or exits.
3662    pub(crate) fn declared_protocol(&self) -> Result<ModuleProtocol, SuperviseError> {
3663        let configured = self
3664            .inner
3665            .configuration
3666            .lock()
3667            .map_err(|_| SuperviseError::StatePoisoned {
3668                module_id: Some(self.inner.module_id.clone()),
3669            })?
3670            .spec
3671            .protocol;
3672        let state = lock_snapshot(&self.inner.snapshot)?;
3673        Ok(state.spawned_protocol.unwrap_or(configured))
3674    }
3675
3676    pub(crate) fn configuration(&self) -> Result<(ModuleSpec, HealthConfig), SuperviseError> {
3677        let configuration =
3678            self.inner
3679                .configuration
3680                .lock()
3681                .map_err(|_| SuperviseError::StatePoisoned {
3682                    module_id: Some(self.inner.module_id.clone()),
3683                })?;
3684        Ok((configuration.spec.clone(), configuration.health.clone()))
3685    }
3686
3687    /// Replace this module's launch spec, keeping its health and drain policy,
3688    /// the way a rescan does for a changed config entry. The running process is
3689    /// untouched; the next spawn (a restart, or a swap's candidate) uses it.
3690    #[cfg(any(test, feature = "test-support"))]
3691    pub async fn update_spec_for_test(&self, spec: ModuleSpec) -> Result<(), SuperviseError> {
3692        let (_, health) = self.configuration()?;
3693        let drain_timeout_ms = u64::try_from(
3694            self.inner
3695                .effective_drain_timeout
3696                .lock()
3697                .unwrap_or_else(|poisoned| poisoned.into_inner())
3698                .as_millis(),
3699        )
3700        .ok();
3701        self.update_configuration(spec, health, drain_timeout_ms)
3702            .await
3703    }
3704
3705    pub(crate) async fn update_configuration(
3706        &self,
3707        spec: ModuleSpec,
3708        health: HealthConfig,
3709        drain_timeout_ms: Option<u64>,
3710    ) -> Result<(), SuperviseError> {
3711        if spec.module_id != self.inner.module_id {
3712            return Err(SuperviseError::InvalidSpec {
3713                reason: "a supervised module's module_id cannot be changed".to_string(),
3714            });
3715        }
3716        validate_spec(&spec)?;
3717        let (reply_tx, reply_rx) = oneshot::channel();
3718        self.inner
3719            .commands
3720            .send(SupervisorCommand::UpdateConfiguration {
3721                spec: spec.clone(),
3722                health: health.clone(),
3723                drain_timeout_ms,
3724                reply: reply_tx,
3725            })
3726            .await
3727            .map_err(|_| SuperviseError::CommandClosed {
3728                module_id: self.inner.module_id.clone(),
3729            })?;
3730        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3731            module_id: self.inner.module_id.clone(),
3732        })?;
3733        let mut configuration =
3734            self.inner
3735                .configuration
3736                .lock()
3737                .map_err(|_| SuperviseError::StatePoisoned {
3738                    module_id: Some(self.inner.module_id.clone()),
3739                })?;
3740        configuration.spec = spec;
3741        configuration.health = health;
3742        Ok(())
3743    }
3744}
3745
3746impl Drop for SupervisedModuleInner {
3747    fn drop(&mut self) {
3748        let Ok(mut monitor) = self.monitor.lock() else {
3749            return;
3750        };
3751        if let Some(monitor) = monitor.as_ref().filter(|monitor| !monitor.is_finished()) {
3752            let _ = update_snapshot(&self.snapshot, Some(&self.module_id), |state| {
3753                state.state = ModuleState::Stopped;
3754                clear_current_process_facts(state);
3755            });
3756            monitor.abort();
3757        }
3758        let _ = monitor.take();
3759    }
3760}
3761
3762#[derive(Debug)]
3763enum SupervisorCommand {
3764    Drain {
3765        reply: oneshot::Sender<Result<(), SuperviseError>>,
3766    },
3767    Retire {
3768        reply: oneshot::Sender<Result<(), SuperviseError>>,
3769    },
3770    Restart {
3771        /// Operator override for this one restart's drain budget, in ms. `None`
3772        /// uses the module's configured/default budget; `Some(0)` cuts
3773        /// immediately (wedge bounce: a stuck request never settles, so
3774        /// waiting only delays recovery).
3775        drain_timeout_ms: Option<u64>,
3776        /// The module's `spawn_generation` when the request was received, before
3777        /// it waited in the command queue. A queued restart whose module has
3778        /// since spawned a newer process is already satisfied (see the handler).
3779        received_at_generation: u64,
3780        /// When the request entered the command queue, so the handler can log
3781        /// how long it waited behind the loop's other work.
3782        queued_at: Instant,
3783        reply: oneshot::Sender<Result<(), SuperviseError>>,
3784    },
3785    Reload {
3786        reply: oneshot::Sender<Result<(), SuperviseError>>,
3787    },
3788    SetEnabled {
3789        enabled: bool,
3790        reply: oneshot::Sender<Result<bool, SuperviseError>>,
3791    },
3792    UpdateConfiguration {
3793        spec: ModuleSpec,
3794        health: HealthConfig,
3795        /// Per-module drain override from the new config; `None` re-resolves to
3796        /// the supervisor-wide default.
3797        drain_timeout_ms: Option<u64>,
3798        reply: oneshot::Sender<()>,
3799    },
3800    Swap {
3801        /// How long the candidate may take to register and declare itself
3802        /// ready. `None` uses [`DEFAULT_SWAP_READY_TIMEOUT`].
3803        ready_timeout: Option<Duration>,
3804        /// Answered at cutover or failure; the incumbent's drain follows.
3805        reply: oneshot::Sender<Result<(), SuperviseError>>,
3806    },
3807}
3808
3809#[derive(Debug)]
3810pub enum SuperviseError {
3811    InvalidSpec {
3812        reason: String,
3813    },
3814    Spawn {
3815        program: PathBuf,
3816        source: io::Error,
3817        cgroup_path: Option<PathBuf>,
3818    },
3819    Cgroup {
3820        module_id: String,
3821        source: io::Error,
3822    },
3823    /// CSPRNG failure generating a reserved module's launch nonce. Fail loud rather
3824    /// than spawn a reserved module without its identity binding.
3825    LaunchNonce {
3826        reason: String,
3827    },
3828    Wait {
3829        module_id: String,
3830        source: io::Error,
3831    },
3832    Kill {
3833        module_id: String,
3834        source: io::Error,
3835    },
3836    Forwarding(ForwardingError),
3837    Registry(RegistryError),
3838    ReloadUnavailable {
3839        module_id: String,
3840        reason: String,
3841    },
3842    /// An operator restart/reload was requested for a module that is currently
3843    /// disabled. Restart/reload cycle a *running* module; a disabled module must
3844    /// be explicitly re-enabled (set_enabled(true)) rather than silently started
3845    /// by a restart, so these commands are rejected instead of re-enabling it.
3846    Disabled {
3847        module_id: String,
3848    },
3849    ReloadFailed {
3850        module_id: String,
3851        reason: String,
3852    },
3853    RegistrationStillActive {
3854        module_id: String,
3855        waited: Duration,
3856    },
3857    StatePoisoned {
3858        module_id: Option<String>,
3859    },
3860    CommandClosed {
3861        module_id: String,
3862    },
3863    /// A restart or reload arrived while a swap's candidate was warming. The
3864    /// swap owns the module until it cuts over or fails; a stop or disable
3865    /// would have aborted it instead.
3866    SwapInProgress {
3867        module_id: String,
3868    },
3869    /// A swap was refused before anything was spawned.
3870    SwapRefused {
3871        module_id: String,
3872        reason: SwapRefusal,
3873    },
3874    /// A swap spawned a candidate and gave up on it. The candidate has been
3875    /// killed and its slot freed; the incumbent was left serving and was never
3876    /// drained, except in the one `CutoverLost` case described on that arm.
3877    SwapFailed {
3878        module_id: String,
3879        arm: SwapFailureArm,
3880        detail: String,
3881        /// How the candidate exited, when it exited on its own before the
3882        /// supervisor gave up on it.
3883        candidate_exit: Option<ExitReport>,
3884    },
3885}
3886
3887/// Why a swap was refused before a candidate was spawned.
3888#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3889pub enum SwapRefusal {
3890    /// The module's config does not declare `overlap: "safe"`.
3891    OverlapExclusive,
3892    /// The module is not registered, so there is no incumbent to keep serving
3893    /// and nothing a swap would improve on; a plain restart is the tool.
3894    NotRegistered,
3895    /// The module does not speak the subc wire, so a candidate could never
3896    /// register or declare itself ready.
3897    ProtocolNone,
3898    /// The supervisor lacks the forwarding table (to cut routes over) or the
3899    /// shared handle (to admit the candidate's HELLO) that a swap needs.
3900    NotConfigured,
3901    /// A swap is already open for this module.
3902    AlreadySwapping,
3903}
3904
3905impl SwapRefusal {
3906    pub fn as_str(self) -> &'static str {
3907        match self {
3908            Self::OverlapExclusive => "overlap_exclusive",
3909            Self::NotRegistered => "not_registered",
3910            Self::ProtocolNone => "protocol_none",
3911            Self::NotConfigured => "not_configured",
3912            Self::AlreadySwapping => "already_swapping",
3913        }
3914    }
3915}
3916
3917/// Which failure arm ended a swap. Every arm but one leaves the incumbent
3918/// serving and undrained; see `CutoverLost`.
3919#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3920pub enum SwapFailureArm {
3921    /// The candidate process could not be started.
3922    SpawnFailed,
3923    /// The candidate did not register within the readiness budget.
3924    NeverRegistered,
3925    /// The candidate registered but did not declare itself ready in time.
3926    NeverReady,
3927    /// The candidate exited before cutover.
3928    CandidateExited,
3929    /// The candidate declared itself ready but failed its health probe.
3930    CandidateUnhealthy,
3931    /// An operator stop, disable or retire arrived while the candidate warmed.
3932    /// The candidate was killed and the operator's command then carried out on
3933    /// the incumbent.
3934    Interrupted,
3935    /// The candidate's connection closed at the moment of cutover. If it
3936    /// closed before forwarding moved, the incumbent is untouched. If it closed
3937    /// between the forwarding and registry halves of cutover, forwarding can no
3938    /// longer route to the incumbent, so the module is restarted plainly.
3939    CutoverLost,
3940}
3941
3942impl SwapFailureArm {
3943    pub fn as_str(self) -> &'static str {
3944        match self {
3945            Self::SpawnFailed => "spawn_failed",
3946            Self::NeverRegistered => "never_registered",
3947            Self::NeverReady => "never_ready",
3948            Self::CandidateExited => "candidate_exited",
3949            Self::CandidateUnhealthy => "candidate_unhealthy",
3950            Self::Interrupted => "interrupted",
3951            Self::CutoverLost => "cutover_lost",
3952        }
3953    }
3954}
3955
3956impl fmt::Display for SuperviseError {
3957    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3958        match self {
3959            Self::InvalidSpec { reason } => write!(f, "invalid module spec: {reason}"),
3960            Self::Spawn {
3961                program,
3962                source,
3963                cgroup_path: Some(cgroup_path),
3964            } => write!(
3965                f,
3966                "failed to place module in cgroup '{}' while spawning '{}': {source}",
3967                cgroup_path.display(),
3968                program.display()
3969            ),
3970            Self::Spawn {
3971                program,
3972                source,
3973                cgroup_path: None,
3974            } => write!(
3975                f,
3976                "failed to spawn module '{}': {source}",
3977                program.display()
3978            ),
3979            Self::Cgroup { module_id, source } => {
3980                write!(
3981                    f,
3982                    "failed to prepare cgroup for module '{module_id}': {source}"
3983                )
3984            }
3985            Self::LaunchNonce { reason } => {
3986                write!(
3987                    f,
3988                    "failed to generate reserved-module launch nonce: {reason}"
3989                )
3990            }
3991            Self::Wait { module_id, source } => {
3992                write!(f, "failed to wait for module '{module_id}': {source}")
3993            }
3994            Self::Kill { module_id, source } => {
3995                write!(f, "failed to kill module '{module_id}': {source}")
3996            }
3997            Self::Forwarding(err) => write!(f, "forwarding error: {err}"),
3998            Self::Registry(err) => write!(f, "registry error: {err}"),
3999            Self::ReloadUnavailable { module_id, reason } => {
4000                write!(f, "reload unavailable for module '{module_id}': {reason}")
4001            }
4002            Self::Disabled { module_id } => {
4003                write!(
4004                    f,
4005                    "module '{module_id}' is disabled; enable it before restart or reload"
4006                )
4007            }
4008            Self::ReloadFailed { module_id, reason } => {
4009                write!(f, "reload failed for module '{module_id}': {reason}")
4010            }
4011            Self::RegistrationStillActive { module_id, waited } => write!(
4012                f,
4013                "module '{module_id}' registration remained active after waiting {waited:?}"
4014            ),
4015            Self::StatePoisoned { module_id } => match module_id {
4016                Some(module_id) => {
4017                    write!(f, "supervisor state for module '{module_id}' was poisoned")
4018                }
4019                None => write!(f, "supervisor state was poisoned"),
4020            },
4021            Self::CommandClosed { module_id } => {
4022                write!(
4023                    f,
4024                    "supervisor command channel for module '{module_id}' is closed"
4025                )
4026            }
4027            Self::SwapInProgress { module_id } => write!(
4028                f,
4029                "module '{module_id}' is being swapped; retry once the swap has cut over or failed, or stop the module to abort the swap"
4030            ),
4031            Self::SwapRefused { module_id, reason } => match reason {
4032                SwapRefusal::OverlapExclusive => write!(
4033                    f,
4034                    "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"
4035                ),
4036                SwapRefusal::NotRegistered => write!(
4037                    f,
4038                    "module '{module_id}' is not registered, so there is no serving process to keep while a replacement warms; use a plain restart"
4039                ),
4040                SwapRefusal::ProtocolNone => write!(
4041                    f,
4042                    "module '{module_id}' is protocol: \"none\" and never registers, so a swap could never see its replacement become ready; use a plain restart"
4043                ),
4044                SwapRefusal::NotConfigured => write!(
4045                    f,
4046                    "module '{module_id}' cannot be swapped: the supervisor was built without the forwarding table or shared handle a swap needs"
4047                ),
4048                SwapRefusal::AlreadySwapping => {
4049                    write!(f, "module '{module_id}' is already being swapped")
4050                }
4051            },
4052            Self::SwapFailed {
4053                module_id,
4054                arm,
4055                detail,
4056                ..
4057            } => write!(
4058                f,
4059                "swap of module '{module_id}' failed ({}): {detail}; the running process was left serving",
4060                arm.as_str()
4061            ),
4062        }
4063    }
4064}
4065
4066impl Error for SuperviseError {
4067    fn source(&self) -> Option<&(dyn Error + 'static)> {
4068        match self {
4069            Self::Spawn { source, .. }
4070            | Self::Cgroup { source, .. }
4071            | Self::Wait { source, .. }
4072            | Self::Kill { source, .. } => Some(source),
4073            Self::Forwarding(err) => Some(err),
4074            Self::Registry(err) => Some(err),
4075            Self::LaunchNonce { .. }
4076            | Self::InvalidSpec { .. }
4077            | Self::ReloadUnavailable { .. }
4078            | Self::Disabled { .. }
4079            | Self::ReloadFailed { .. }
4080            | Self::RegistrationStillActive { .. }
4081            | Self::StatePoisoned { .. }
4082            | Self::CommandClosed { .. }
4083            | Self::SwapInProgress { .. }
4084            | Self::SwapRefused { .. }
4085            | Self::SwapFailed { .. } => None,
4086        }
4087    }
4088}
4089
4090pub(crate) fn validate_spec(spec: &ModuleSpec) -> Result<(), SuperviseError> {
4091    if spec.module_id.trim().is_empty() {
4092        return Err(SuperviseError::InvalidSpec {
4093            reason: "module_id must not be empty".to_string(),
4094        });
4095    }
4096
4097    Ok(())
4098}
4099
4100#[derive(Debug, Default)]
4101struct HealthProbeRuntime {
4102    configured_health: Option<HealthConfig>,
4103    registered_connection: Option<crate::ConnectionId>,
4104    advertised: bool,
4105    next_probe_at: Option<Instant>,
4106    probe_index: u64,
4107}
4108
4109fn running_protocol(spec: &ModuleSpec, snapshot: &SharedSnapshot) -> ModuleProtocol {
4110    lock_snapshot(snapshot)
4111        .ok()
4112        .and_then(|state| state.spawned_protocol)
4113        .unwrap_or(spec.protocol)
4114}
4115
4116impl HealthProbeRuntime {
4117    fn refresh_registration(
4118        &mut self,
4119        spec: &ModuleSpec,
4120        runtime: &SupervisorRuntimeConfig,
4121        registry: &Registry,
4122        snapshot: &SharedSnapshot,
4123    ) {
4124        if self.configured_health.as_ref() != Some(&runtime.health) {
4125            self.configured_health = Some(runtime.health.clone());
4126            self.next_probe_at = None;
4127            self.registered_connection = None;
4128            self.probe_index = 0;
4129        }
4130        // A non-wire process never registers. Only an explicitly configured
4131        // HTTP endpoint can arm its health probe; an absent HELLO is not a
4132        // health failure for that kind of process.
4133        if running_protocol(spec, snapshot) == ModuleProtocol::None {
4134            self.registered_connection = None;
4135            self.advertised = runtime.health.http.is_some();
4136            if !self.advertised {
4137                self.next_probe_at = None;
4138                let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4139                    let unknown = ModuleHealthStatus::default();
4140                    if state.health != unknown {
4141                        state.health = unknown;
4142                    }
4143                });
4144            } else if self.next_probe_at.is_none() {
4145                self.next_probe_at = Some(
4146                    Instant::now()
4147                        + jittered_health_delay(&spec.module_id, 0, runtime.health.cadence),
4148                );
4149            }
4150            return;
4151        }
4152
4153        let registration = match registry.get_module(&spec.module_id) {
4154            Ok(registration) => registration,
4155            Err(err) => {
4156                warn!(module_id = %spec.module_id, error = %err, "health prober could not read registry");
4157                self.advertised = false;
4158                self.next_probe_at = None;
4159                return;
4160            }
4161        };
4162
4163        let Some(registration) = registration else {
4164            self.registered_connection = None;
4165            self.advertised = false;
4166            self.next_probe_at = None;
4167            return;
4168        };
4169
4170        let advertised = registration
4171            .control_ops
4172            .iter()
4173            .any(|op| op == MODULE_CONTROL_OP_HEALTH_CHECK);
4174        if !advertised {
4175            self.registered_connection = Some(registration.connection_id);
4176            self.advertised = false;
4177            self.next_probe_at = None;
4178            let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4179                if state.health.status != SupervisorHealthStatus::Unknown
4180                    || state.health.consecutive_failures != 0
4181                    || state.health.last_probe_ms.is_some()
4182                    || state.health.detail.is_some()
4183                    || state.health.metrics.is_some()
4184                {
4185                    state.health.status = SupervisorHealthStatus::Unknown;
4186                    state.health.consecutive_failures = 0;
4187                    state.health.last_probe_ms = None;
4188                    state.health.detail = None;
4189                    state.health.metrics = None;
4190                }
4191            });
4192            return;
4193        }
4194
4195        let reregistered = self.registered_connection != Some(registration.connection_id);
4196        self.registered_connection = Some(registration.connection_id);
4197        self.advertised = true;
4198        if reregistered || self.next_probe_at.is_none() {
4199            self.probe_index = 0;
4200            self.next_probe_at = Some(
4201                Instant::now() + jittered_health_delay(&spec.module_id, 0, runtime.health.cadence),
4202            );
4203            let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4204                state.health.status = SupervisorHealthStatus::Unknown;
4205                state.health.consecutive_failures = 0;
4206                state.health.detail = None;
4207                state.health.metrics = None;
4208            });
4209        }
4210    }
4211
4212    fn wake_after(&self) -> Option<Duration> {
4213        if !self.advertised {
4214            return None;
4215        }
4216        self.next_probe_at
4217            .map(|next| next.saturating_duration_since(Instant::now()))
4218    }
4219
4220    fn due(&self) -> bool {
4221        self.advertised
4222            && self
4223                .next_probe_at
4224                .is_some_and(|next| Instant::now() >= next)
4225    }
4226
4227    fn schedule_next(&mut self, spec: &ModuleSpec, cadence: Duration) {
4228        self.probe_index = self.probe_index.wrapping_add(1);
4229        self.next_probe_at = Some(
4230            Instant::now() + jittered_health_delay(&spec.module_id, self.probe_index, cadence),
4231        );
4232    }
4233}
4234
4235/// What a failed health probe actually OBSERVED, kept apart from how it reads.
4236///
4237/// This was a struct with a single `message: String`, and every one of the
4238/// fifteen construction sites collapsed into it. Each site knows exactly what it
4239/// saw -- the lane is gone, the module did not answer in time, the module
4240/// answered with the wrong thing -- and `handle_health_probe_failure` then
4241/// treated all of them identically: increment a counter, compare to a threshold,
4242/// restart the module. THE DISTINCTION EXISTED AT EVERY CALL SITE AND WAS
4243/// DESTROYED BEFORE THE DECISION THAT NEEDED IT.
4244///
4245/// The distinction that matters is not severity, it is EVIDENTIAL WEIGHT:
4246///
4247/// * `LaneDead` is PROOF. The module's control connection is gone; nothing will
4248///   answer on it again.
4249/// * `NoAnswer` is ABSENCE OF EVIDENCE. It is consistent with a wedged module
4250///   AND with a perfectly healthy one that lost a CPU race -- which is what
4251///   happens under machine load, and is how this supervisor killed a healthy
4252///   module three times in one day.
4253/// * `BadAnswer` proves the module is ALIVE. It replied; the reply was wrong.
4254///   Restarting on it is defensible, but it is not the silence case and should
4255///   never be counted as one.
4256/// * `Misconfigured` is a daemon-side fault. The module has not been asked
4257///   anything, so it cannot be evidence about the module at all.
4258///
4259/// The asymmetry is the whole point: under saturation the WEAKEST signal is the
4260/// one that fires most often, and while every variant collapsed into one string
4261/// it carried the same weight as the strongest.
4262///
4263/// LIVE BEHAVIOUR TODAY, stated here because this doc block describes the
4264/// DESIGN and a reader stopping at it gets the build backwards: the restart
4265/// decision does NOT yet consult this classification -- consecutive `NoAnswer`
4266/// probes still increment the failure streak and drive escalation at the
4267/// threshold (see `is_proof_of_death` below for why that is deliberate and
4268/// what gates the change). Absence of evidence restarts modules today.
4269#[derive(Debug)]
4270enum HealthProbeEvidence {
4271    /// The module's control lane is gone. Proof of death.
4272    LaneDead,
4273    /// No reply within the deadline. Proves nothing about the module's state.
4274    NoAnswer,
4275    /// The module replied, but not with a usable health report. Proves it is alive.
4276    BadAnswer,
4277    /// The daemon could not ask. Says nothing about the module.
4278    Misconfigured,
4279}
4280
4281#[derive(Debug)]
4282struct HealthProbeError {
4283    evidence: HealthProbeEvidence,
4284    message: String,
4285}
4286
4287impl HealthProbeError {
4288    fn lane_dead(message: impl Into<String>) -> Self {
4289        Self::with(HealthProbeEvidence::LaneDead, message)
4290    }
4291
4292    fn no_answer(message: impl Into<String>) -> Self {
4293        Self::with(HealthProbeEvidence::NoAnswer, message)
4294    }
4295
4296    fn bad_answer(message: impl Into<String>) -> Self {
4297        Self::with(HealthProbeEvidence::BadAnswer, message)
4298    }
4299
4300    fn misconfigured(message: impl Into<String>) -> Self {
4301        Self::with(HealthProbeEvidence::Misconfigured, message)
4302    }
4303
4304    fn with(evidence: HealthProbeEvidence, message: impl Into<String>) -> Self {
4305        Self {
4306            evidence,
4307            message: message.into(),
4308        }
4309    }
4310
4311    /// Whether this observation is proof the module cannot serve.
4312    ///
4313    /// Only `LaneDead` qualifies. `NoAnswer` is deliberately excluded: it is the
4314    /// variant that fires under CPU starvation, and treating it as proof is the
4315    /// defect this enum exists to make impossible to reintroduce silently.
4316    ///
4317    /// NOT YET CONSULTED BY THE RESTART DECISION, deliberately. Requiring proof
4318    /// to restart also needs a bound for the case it excludes -- a genuinely
4319    /// wedged module, alive but never answering -- and that bound must come from
4320    /// the distribution of real late-answer latencies, which nothing measures
4321    /// yet. Landing the classification first makes the later change a one-line
4322    /// decision against evidence that already exists, rather than two unproven
4323    /// changes at once.
4324    #[allow(dead_code)]
4325    fn is_proof_of_death(&self) -> bool {
4326        matches!(self.evidence, HealthProbeEvidence::LaneDead)
4327    }
4328
4329    /// Short stable label for logs and the health snapshot.
4330    ///
4331    /// An operator reading `ck health` currently cannot tell "the module is gone"
4332    /// from "the module did not answer in five seconds", because both render as
4333    /// prose in the same field. These labels are what make the two
4334    /// distinguishable at a glance, and they are what a later restart-policy
4335    /// change will be argued from.
4336    fn label(&self) -> &'static str {
4337        match self.evidence {
4338            HealthProbeEvidence::LaneDead => "lane-dead",
4339            HealthProbeEvidence::NoAnswer => "no-answer",
4340            HealthProbeEvidence::BadAnswer => "bad-answer",
4341            HealthProbeEvidence::Misconfigured => "daemon-misconfigured",
4342        }
4343    }
4344}
4345
4346impl fmt::Display for HealthProbeError {
4347    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4348        f.write_str(&self.message)
4349    }
4350}
4351
4352async fn run_health_probe_cycle(
4353    spec: &ModuleSpec,
4354    runtime: &SupervisorRuntimeConfig,
4355    registry: &Registry,
4356    process_liveness: &SupervisorProcessLiveness,
4357    snapshot: &SharedSnapshot,
4358    child: &mut Option<SupervisedChild>,
4359) {
4360    let now_ms = unix_ms_now();
4361    let http = (running_protocol(spec, snapshot) == ModuleProtocol::None)
4362        .then_some(runtime.health.http.as_deref())
4363        .flatten();
4364    let result = match http {
4365        Some(url) => probe_http_health(url, runtime.health.deadline).await,
4366        None => probe_module_health(&spec.module_id, runtime, None).await,
4367    };
4368    match result {
4369        Ok(report) => {
4370            handle_health_report(
4371                spec,
4372                runtime,
4373                registry,
4374                process_liveness,
4375                snapshot,
4376                child,
4377                report,
4378                now_ms,
4379            )
4380            .await;
4381        }
4382        Err(err) => {
4383            if http.is_some() {
4384                let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4385                    state.health.status = SupervisorHealthStatus::Failing;
4386                });
4387            }
4388            handle_health_probe_failure(
4389                spec,
4390                runtime,
4391                registry,
4392                process_liveness,
4393                snapshot,
4394                child,
4395                err,
4396                now_ms,
4397            )
4398            .await;
4399        }
4400    }
4401}
4402
4403pub(crate) struct HttpProbeTarget<'a> {
4404    address: std::net::SocketAddr,
4405    localhost: bool,
4406    authority: &'a str,
4407    path: String,
4408}
4409
4410/// Resolve only literal loopback endpoints, without DNS, redirects, proxies,
4411/// or TLS. A URL cannot turn a local health check into an outbound connection.
4412pub(crate) fn parse_http_probe_url(url: &str) -> Result<HttpProbeTarget<'_>, String> {
4413    if url.bytes().any(|b| b <= b' ' || b == 127) || url.contains('#') {
4414        return Err("must not contain whitespace, controls, or a fragment".into());
4415    }
4416    let rest = url
4417        .strip_prefix("http://")
4418        .ok_or("must use plain http://")?;
4419    let split = rest.find(['/', '?']).unwrap_or(rest.len());
4420    let (authority, suffix) = rest.split_at(split);
4421    let (host, port) = if let Some(rest) = authority.strip_prefix("[::1]") {
4422        ("::1", rest)
4423    } else {
4424        let split = authority.find(':').unwrap_or(authority.len());
4425        authority.split_at(split)
4426    };
4427    let ip: std::net::IpAddr = match host {
4428        "127.0.0.1" | "localhost" => std::net::Ipv4Addr::LOCALHOST.into(),
4429        "::1" => std::net::Ipv6Addr::LOCALHOST.into(),
4430        _ => return Err("host must be 127.0.0.1, [::1], or localhost".into()),
4431    };
4432    let port = if port.is_empty() {
4433        80
4434    } else {
4435        port.strip_prefix(':')
4436            .and_then(|p| p.parse::<u16>().ok())
4437            .filter(|p| *p > 0)
4438            .ok_or("must have a valid nonzero TCP port")?
4439    };
4440    let path = if suffix.is_empty() {
4441        "/".into()
4442    } else if suffix.starts_with('?') {
4443        format!("/{suffix}")
4444    } else {
4445        suffix.into()
4446    };
4447    Ok(HttpProbeTarget {
4448        address: std::net::SocketAddr::new(ip, port),
4449        localhost: host == "localhost",
4450        authority,
4451        path,
4452    })
4453}
4454
4455async fn probe_http_health(
4456    url: &str,
4457    deadline: Duration,
4458) -> Result<HealthReport, HealthProbeError> {
4459    use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader};
4460    let target = parse_http_probe_url(url).map_err(HealthProbeError::misconfigured)?;
4461    // Keep partial diagnostics outside the timed future so cancellation does
4462    // not discard a status line or body bytes already received.
4463    let mut response_status = String::new();
4464    let mut body = Vec::new();
4465    let probe = async {
4466        // Resolve localhost ourselves so a hosts-file override cannot turn
4467        // this into an outbound request, while IPv6-only local servers work.
4468        let connection = match tokio::net::TcpStream::connect(target.address).await {
4469            Err(_) if target.localhost => {
4470                tokio::net::TcpStream::connect((
4471                    std::net::Ipv6Addr::LOCALHOST,
4472                    target.address.port(),
4473                ))
4474                .await
4475            }
4476            result => result,
4477        };
4478        let mut stream = connection.map_err(|error| {
4479            HealthProbeError::no_answer(format!("HTTP connect failed: {error}"))
4480        })?;
4481        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
4482            .map_err(|error| HealthProbeError::no_answer(format!("HTTP write failed: {error}")))?;
4483        let mut reader = BufReader::new(stream);
4484        let mut budget = 16 * 1024;
4485        let status = http_line(&mut reader, &mut budget).await?;
4486        let mut words = status.split_ascii_whitespace();
4487        let version = words.next();
4488        let code = words
4489            .next()
4490            .filter(|word| word.len() == 3)
4491            .and_then(|word| word.parse::<u16>().ok());
4492        if !matches!(version, Some("HTTP/1.1" | "HTTP/1.0"))
4493            || !code.is_some_and(|code| (100..600).contains(&code))
4494        {
4495            return Err(HealthProbeError::bad_answer(format!(
4496                "invalid HTTP status: {status}"
4497            )));
4498        }
4499        let code = code.expect("validated status code");
4500        response_status = status.clone();
4501        let mut length = None;
4502        let mut chunked = false;
4503        loop {
4504            let line = http_line(&mut reader, &mut budget).await?;
4505            if line.is_empty() {
4506                break;
4507            }
4508            if let Some((name, value)) = line.split_once(':') {
4509                if name.eq_ignore_ascii_case("content-length") {
4510                    length = Some(value.trim().parse::<u64>().map_err(|_| {
4511                        HealthProbeError::bad_answer("invalid HTTP Content-Length")
4512                    })?);
4513                } else if name.eq_ignore_ascii_case("transfer-encoding") {
4514                    chunked = value.trim().eq_ignore_ascii_case("chunked");
4515                }
4516            }
4517        }
4518        if chunked {
4519            while body.len() < 200 {
4520                let line = http_line(&mut reader, &mut budget).await?;
4521                let size = u64::from_str_radix(line.split(';').next().unwrap_or("").trim(), 16)
4522                    .map_err(|_| HealthProbeError::bad_answer("invalid HTTP chunk size"))?;
4523                if size == 0 {
4524                    break;
4525                }
4526                let count = size.min((200 - body.len()) as u64) as usize;
4527                let start = body.len();
4528                (&mut reader)
4529                    .take(count as u64)
4530                    .read_to_end(&mut body)
4531                    .await
4532                    .map_err(|error| {
4533                        HealthProbeError::no_answer(format!("HTTP body read failed: {error}"))
4534                    })?;
4535                if body.len() - start != count {
4536                    return Err(HealthProbeError::bad_answer("truncated HTTP chunk"));
4537                }
4538                if size > count as u64 || body.len() == 200 {
4539                    break;
4540                }
4541                if !http_line(&mut reader, &mut budget).await?.is_empty() {
4542                    return Err(HealthProbeError::bad_answer("invalid HTTP chunk delimiter"));
4543                }
4544            }
4545        } else {
4546            reader
4547                .take(length.unwrap_or(200).min(200))
4548                .read_to_end(&mut body)
4549                .await
4550                .map_err(|error| {
4551                    HealthProbeError::no_answer(format!("HTTP body read failed: {error}"))
4552                })?;
4553        }
4554        if (200..300).contains(&code) {
4555            Ok(HealthReport::ok())
4556        } else {
4557            Err(HealthProbeError::bad_answer(
4558                "HTTP health endpoint returned non-2xx",
4559            ))
4560        }
4561    };
4562    let mut result = timeout(deadline, probe).await.unwrap_or_else(|_| {
4563        Err(HealthProbeError::no_answer(format!(
4564            "HTTP probe timed out after {deadline:?}"
4565        )))
4566    });
4567    if let Err(error) = &mut result {
4568        if !response_status.is_empty() {
4569            error.message = format!(
4570                "{}; {response_status}: {}",
4571                error.message,
4572                String::from_utf8_lossy(&body)
4573            );
4574        }
4575    }
4576    result
4577}
4578
4579async fn http_line(
4580    reader: &mut tokio::io::BufReader<tokio::net::TcpStream>,
4581    remaining: &mut usize,
4582) -> Result<String, HealthProbeError> {
4583    use tokio::io::{AsyncBufReadExt, AsyncReadExt};
4584    let mut line = Vec::new();
4585    (&mut *reader)
4586        .take(*remaining as u64)
4587        .read_until(b'\n', &mut line)
4588        .await
4589        .map_err(|error| {
4590            HealthProbeError::no_answer(format!("HTTP header read failed: {error}"))
4591        })?;
4592    *remaining -= line.len();
4593    if !line.ends_with(b"\r\n") {
4594        return Err(HealthProbeError::bad_answer(
4595            "HTTP headers are incomplete or exceed 16 KiB",
4596        ));
4597    }
4598    line.truncate(line.len() - 2);
4599    String::from_utf8(line).map_err(|_| HealthProbeError::bad_answer("HTTP header is not UTF-8"))
4600}
4601
4602async fn probe_module_health(
4603    module_id: &str,
4604    runtime: &SupervisorRuntimeConfig,
4605    drain_deadline: Option<Instant>,
4606) -> Result<HealthReport, HealthProbeError> {
4607    let Some(forwarding) = runtime.forwarding.as_ref() else {
4608        return Err(HealthProbeError::misconfigured(
4609            "supervisor was not configured with a forwarding table",
4610        ));
4611    };
4612    let probe_started_at = Instant::now();
4613    let mut deadline = probe_started_at + runtime.health.deadline;
4614    if let Some(drain_deadline) = drain_deadline {
4615        deadline = deadline.min(drain_deadline);
4616    }
4617    let pending = if drain_deadline.is_some() {
4618        forwarding.begin_drain_health_probe_rpc_for(
4619            module_id,
4620            MODULE_CONTROL_OP_HEALTH_CHECK,
4621            probe_started_at,
4622            deadline,
4623        )
4624    } else {
4625        forwarding.begin_health_probe_rpc_for(
4626            module_id,
4627            MODULE_CONTROL_OP_HEALTH_CHECK,
4628            probe_started_at,
4629            deadline,
4630        )
4631    }
4632    .map_err(|err| {
4633        // The endpoint is not registered, so there is no live control lane to
4634        // ask. That is the module being absent, not slow.
4635        HealthProbeError::lane_dead(format!("failed to begin health.check RPC: {err}"))
4636    })?;
4637    await_health_probe(forwarding, pending, deadline, runtime.health.deadline).await
4638}
4639
4640/// [`probe_module_health`] for one endpoint rather than the id's active one.
4641///
4642/// A swap probes two processes that no by-id lookup reaches: its candidate
4643/// before cutover, and its superseded incumbent (for busy gauges) while the
4644/// incumbent drains. `deadline_cap` bounds the probe the way a drain deadline
4645/// bounds the by-id drain probe.
4646async fn probe_endpoint_health(
4647    endpoint: crate::ModuleEndpointId,
4648    runtime: &SupervisorRuntimeConfig,
4649    deadline_cap: Option<Instant>,
4650) -> Result<HealthReport, HealthProbeError> {
4651    let Some(forwarding) = runtime.forwarding.as_ref() else {
4652        return Err(HealthProbeError::misconfigured(
4653            "supervisor was not configured with a forwarding table",
4654        ));
4655    };
4656    let probe_started_at = Instant::now();
4657    let mut deadline = probe_started_at + runtime.health.deadline;
4658    if let Some(cap) = deadline_cap {
4659        deadline = deadline.min(cap);
4660    }
4661    let pending = forwarding
4662        .begin_endpoint_health_probe_rpc_for(
4663            endpoint,
4664            MODULE_CONTROL_OP_HEALTH_CHECK,
4665            probe_started_at,
4666            deadline,
4667        )
4668        .map_err(|err| {
4669            HealthProbeError::lane_dead(format!("failed to begin health.check RPC: {err}"))
4670        })?;
4671    await_health_probe(forwarding, pending, deadline, runtime.health.deadline).await
4672}
4673
4674/// Send a begun health probe and classify its answer.
4675async fn await_health_probe(
4676    forwarding: &ForwardingTable,
4677    pending: PendingModuleControlRpc,
4678    deadline: Instant,
4679    probe_budget: Duration,
4680) -> Result<HealthReport, HealthProbeError> {
4681    let PendingModuleControlRpc {
4682        endpoint,
4683        module_sink,
4684        negotiated_ver,
4685        corr,
4686        receiver,
4687    } = pending;
4688    let body = serde_json::to_vec(&ModuleControlRequest::HealthCheck {}).map_err(|err| {
4689        HealthProbeError::misconfigured(format!("failed to encode health.check: {err}"))
4690    })?;
4691    let frame = Frame::build_with_version(
4692        negotiated_ver,
4693        FrameType::Request,
4694        control_flags(),
4695        0,
4696        0,
4697        corr,
4698        body,
4699    )
4700    .map_err(|err| {
4701        HealthProbeError::misconfigured(format!("failed to build health.check frame: {err}"))
4702    })?;
4703
4704    // The enqueue itself must be bounded by the probe deadline: FrameSink.send
4705    // blocks waiting for capacity when the module's egress queue is full, and an
4706    // unbounded await here freezes the whole supervision actor (it stops polling
4707    // Child::wait and supervisor commands), making the module unrecoverable
4708    // in-band. On timeout the probe fails like any transport failure.
4709    match timeout_at(deadline, module_sink.send(frame)).await {
4710        Ok(Ok(())) => {}
4711        Ok(Err(err)) => {
4712            let _ = forwarding.cancel_module_control_rpc(endpoint, corr);
4713            // A closed sink means the module's egress channel is gone -- the
4714            // receiving half is dropped when its connection tears down. Proof.
4715            return Err(HealthProbeError::lane_dead(format!(
4716                "failed to send health.check: {err}"
4717            )));
4718        }
4719        Err(_elapsed) => {
4720            let _ = forwarding.cancel_module_control_rpc(endpoint, corr);
4721            // A full egress queue means the module is not draining its socket, which
4722            // is consistent with a wedged module AND with one whose reader is merely
4723            // starved. Silence, not proof.
4724            return Err(HealthProbeError::no_answer(
4725                "health.check send timed out before enqueue (module egress full)",
4726            ));
4727        }
4728    }
4729
4730    match timeout_at(deadline, receiver).await {
4731        // Each arm records WHAT WAS OBSERVED. Four of them are the module
4732        // demonstrably answering -- rejected, non-health, malformed, wrong op --
4733        // and those prove it is alive even though the probe failed.
4734        Ok(Ok(ModuleControlRpcOutcome::Response(response))) => {
4735            response.health_report().ok_or_else(|| {
4736                HealthProbeError::bad_answer("health.check RPC returned a non-health response")
4737            })
4738        }
4739        Ok(Ok(ModuleControlRpcOutcome::Rejected(body))) => Err(HealthProbeError::bad_answer(
4740            format!("health.check rejected: {}", body.message),
4741        )),
4742        Ok(Ok(ModuleControlRpcOutcome::ModuleGone(message))) => {
4743            Err(HealthProbeError::lane_dead(message))
4744        }
4745        Ok(Ok(ModuleControlRpcOutcome::MalformedResponse(message))) => {
4746            Err(HealthProbeError::bad_answer(message))
4747        }
4748        Ok(Ok(ModuleControlRpcOutcome::UnexpectedOp { expected, actual })) => {
4749            Err(HealthProbeError::bad_answer(format!(
4750                "expected module-control op '{expected}', got '{actual}'"
4751            )))
4752        }
4753        // A reply that crosses the deadline before this waiter observes it is
4754        // still proof of life. The forwarding path records its end-to-end latency
4755        // before delivering this classification.
4756        Ok(Ok(ModuleControlRpcOutcome::DeadlineElapsed)) => Err(HealthProbeError::bad_answer(
4757            "module answered health.check after its daemon deadline",
4758        )),
4759        Ok(Err(_)) => Err(HealthProbeError::misconfigured(
4760            "health.check waiter was canceled before the module responded",
4761        )),
4762        Err(_) => {
4763            let _ = forwarding.tombstone_health_probe_rpc(endpoint, corr);
4764            Err(HealthProbeError::no_answer(format!(
4765                "module did not answer health.check within {probe_budget:?}"
4766            )))
4767        }
4768    }
4769}
4770
4771#[allow(clippy::too_many_arguments)]
4772async fn handle_health_report(
4773    spec: &ModuleSpec,
4774    runtime: &SupervisorRuntimeConfig,
4775    registry: &Registry,
4776    process_liveness: &SupervisorProcessLiveness,
4777    snapshot: &SharedSnapshot,
4778    child: &mut Option<SupervisedChild>,
4779    report: HealthReport,
4780    now_ms: u64,
4781) {
4782    let status = supervisor_health_status(report.status);
4783    let detail = report.detail.clone();
4784    let metrics = truncate_health_metrics(report.metrics);
4785    let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4786        state.health.status = status;
4787        state.health.last_probe_ms = Some(now_ms);
4788        state.health.detail = detail.clone();
4789        state.health.metrics = metrics.clone();
4790        state.health.consecutive_failures = 0;
4791    });
4792
4793    let action = match report.status {
4794        HealthStatus::Ok => return,
4795        HealthStatus::Degraded => runtime.health.on_degraded,
4796        HealthStatus::Failing => runtime.health.on_failing,
4797    };
4798    apply_l3_health_action(
4799        spec,
4800        runtime,
4801        registry,
4802        process_liveness,
4803        snapshot,
4804        child,
4805        status,
4806        detail.as_deref(),
4807        action,
4808        now_ms,
4809    )
4810    .await;
4811}
4812
4813#[allow(clippy::too_many_arguments)]
4814async fn handle_health_probe_failure(
4815    spec: &ModuleSpec,
4816    runtime: &SupervisorRuntimeConfig,
4817    registry: &Registry,
4818    process_liveness: &SupervisorProcessLiveness,
4819    snapshot: &SharedSnapshot,
4820    child: &mut Option<SupervisedChild>,
4821    err: HealthProbeError,
4822    now_ms: u64,
4823) {
4824    let threshold = runtime.health.failure_threshold.max(1);
4825    let mut failures = 0;
4826    // Carry the evidence class into the operator-visible detail. Without it,
4827    // "module did not answer within 5s" and "the control lane is gone" are two
4828    // prose strings in the same field, and the reader has to know the codebase to
4829    // tell which one is proof of anything.
4830    let detail = format!("[{}] {err}", err.label());
4831    let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4832        // A failed wire probe invalidates the last report, even before the
4833        // restart threshold. HTTP probes already mark failures as Failing.
4834        if state.spawned_protocol.unwrap_or(spec.protocol) == ModuleProtocol::Subc {
4835            state.health.status = SupervisorHealthStatus::Unknown;
4836        }
4837        state.health.last_probe_ms = Some(now_ms);
4838        state.health.consecutive_failures = state.health.consecutive_failures.saturating_add(1);
4839        state.health.detail = Some(detail.clone());
4840        state.health.metrics = None;
4841        failures = state.health.consecutive_failures;
4842    });
4843
4844    if failures < threshold {
4845        warn!(
4846            module_id = %spec.module_id,
4847            consecutive_failures = failures,
4848            threshold,
4849            evidence = err.label(),
4850            detail = %detail,
4851            "health.check probe failed"
4852        );
4853        return;
4854    }
4855
4856    let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4857        state.state = ModuleState::Unresponsive;
4858        state.health.status = SupervisorHealthStatus::Unresponsive;
4859    });
4860    // The evidence class is logged at the kill site because this is the line an
4861    // operator reads after an unexplained restart. A streak of `no-answer` under
4862    // machine load is the known false-positive shape; a `lane-dead` is not.
4863    if runtime.health.critical {
4864        error!(
4865            module_id = %spec.module_id,
4866            status = "unresponsive",
4867            evidence = err.label(),
4868            detail = %detail,
4869            "critical module health alert"
4870        );
4871    } else {
4872        warn!(
4873            module_id = %spec.module_id,
4874            status = "unresponsive",
4875            evidence = err.label(),
4876            detail = %detail,
4877            "module health threshold breached"
4878        );
4879    }
4880    if let Err(err) = health_restart_child(
4881        spec,
4882        runtime,
4883        registry,
4884        process_liveness,
4885        snapshot,
4886        child,
4887        SupervisorHealthStatus::Unresponsive,
4888        Some(&detail),
4889        now_ms,
4890    )
4891    .await
4892    {
4893        error!(module_id = %spec.module_id, error = %err, "health-triggered restart failed");
4894    }
4895}
4896
4897#[allow(clippy::too_many_arguments)]
4898async fn apply_l3_health_action(
4899    spec: &ModuleSpec,
4900    runtime: &SupervisorRuntimeConfig,
4901    registry: &Registry,
4902    process_liveness: &SupervisorProcessLiveness,
4903    snapshot: &SharedSnapshot,
4904    child: &mut Option<SupervisedChild>,
4905    status: SupervisorHealthStatus,
4906    detail: Option<&str>,
4907    action: HealthAction,
4908    now_ms: u64,
4909) {
4910    record_health_action(snapshot, &spec.module_id, action.to_string(), now_ms);
4911    match action {
4912        HealthAction::Report => {
4913            info!(
4914                module_id = %spec.module_id,
4915                status = ?status,
4916                detail,
4917                "module reported non-ok health"
4918            );
4919        }
4920        HealthAction::Alert => {
4921            error!(
4922                module_id = %spec.module_id,
4923                status = ?status,
4924                detail,
4925                "module health alert"
4926            );
4927        }
4928        HealthAction::Restart => {
4929            if let Err(err) = health_restart_child(
4930                spec,
4931                runtime,
4932                registry,
4933                process_liveness,
4934                snapshot,
4935                child,
4936                status,
4937                detail,
4938                now_ms,
4939            )
4940            .await
4941            {
4942                error!(module_id = %spec.module_id, error = %err, "health-triggered restart failed");
4943            }
4944        }
4945    }
4946}
4947
4948#[allow(clippy::too_many_arguments)]
4949async fn health_restart_child(
4950    spec: &ModuleSpec,
4951    runtime: &SupervisorRuntimeConfig,
4952    registry: &Registry,
4953    process_liveness: &SupervisorProcessLiveness,
4954    snapshot: &SharedSnapshot,
4955    child: &mut Option<SupervisedChild>,
4956    status: SupervisorHealthStatus,
4957    detail: Option<&str>,
4958    now_ms: u64,
4959) -> Result<(), SuperviseError> {
4960    let (enabled, schedule) = {
4961        let mut state = lock_snapshot(snapshot)?;
4962        let enabled = state.enabled;
4963        let schedule = if enabled {
4964            state.next_crash_restart(&runtime.restart_policy, Instant::now())
4965        } else {
4966            None
4967        };
4968        (enabled, schedule)
4969    };
4970
4971    if !enabled {
4972        return Err(SuperviseError::Disabled {
4973            module_id: spec.module_id.clone(),
4974        });
4975    }
4976
4977    if schedule.is_none() {
4978        record_health_action(snapshot, &spec.module_id, "failed".to_string(), now_ms);
4979        error!(
4980            module_id = %spec.module_id,
4981            status = ?status,
4982            detail,
4983            max_restarts = runtime.restart_policy.max_restarts,
4984            window_secs = runtime.restart_policy.window.as_secs(),
4985            reason = %runtime.restart_policy.budget_exhausted_detail(),
4986            "health restart budget exhausted; marking module failed"
4987        );
4988        let stop_notice = begin_forwarding_drain_if_configured(
4989            spec,
4990            runtime,
4991            registry,
4992            snapshot,
4993            Some(true),
4994            RouteCloseReason::Disable,
4995        )
4996        .await?;
4997        update_snapshot(snapshot, Some(&spec.module_id), |state| {
4998            state.drain_disposition_detail = Some(runtime.restart_policy.budget_exhausted_detail());
4999        })?;
5000        drain_optional_child(
5001            &spec.module_id,
5002            spec.protocol,
5003            stop_notice,
5004            registry,
5005            runtime.forwarding.as_deref(),
5006            snapshot,
5007            &runtime.terminal_ring,
5008            &runtime.spawn_events,
5009            child,
5010            runtime.drain_timeout,
5011            ModuleState::Failed,
5012            Some(true),
5013        )
5014        .await?;
5015        process_liveness.untrack_if_current(&spec.module_id, snapshot);
5016        return Ok(());
5017    }
5018
5019    let schedule = schedule.expect("a health restart must have a crash-restart schedule");
5020    let mut restart_count = 0;
5021    update_snapshot(snapshot, Some(&spec.module_id), |state| {
5022        restart_count = state.crash_restarts.len();
5023        state.state = ModuleState::Unresponsive;
5024        state.health.status = status;
5025        state.health.last_action = Some(HealthAction::Restart.to_string());
5026        state.health.last_action_ms = Some(now_ms);
5027    })?;
5028    warn!(
5029        module_id = %spec.module_id,
5030        status = ?status,
5031        detail,
5032        restart_count,
5033        restart_in_window = schedule.restart_in_window,
5034        delay_ms = schedule.delay.as_millis() as u64,
5035        "health-triggered module restart"
5036    );
5037
5038    let stop_notice = begin_forwarding_drain_if_configured(
5039        spec,
5040        runtime,
5041        registry,
5042        snapshot,
5043        Some(true),
5044        RouteCloseReason::Restart,
5045    )
5046    .await?;
5047    drain_optional_child(
5048        &spec.module_id,
5049        spec.protocol,
5050        stop_notice,
5051        registry,
5052        runtime.forwarding.as_deref(),
5053        snapshot,
5054        &runtime.terminal_ring,
5055        &runtime.spawn_events,
5056        child,
5057        runtime.drain_timeout,
5058        ModuleState::Restarting,
5059        Some(true),
5060    )
5061    .await?;
5062    schedule_respawn(
5063        runtime,
5064        snapshot,
5065        &spec.module_id,
5066        schedule.delay,
5067        RespawnKind::Spawn,
5068    )
5069}
5070
5071fn cancel_deferred_reload(runtime: &SupervisorRuntimeConfig, module_id: &str, reason: &str) {
5072    if let Some(reply) = runtime
5073        .deferred_reload_reply
5074        .lock()
5075        .unwrap_or_else(|p| p.into_inner())
5076        .take()
5077    {
5078        let _ = reply.send(Err(SuperviseError::ReloadFailed {
5079            module_id: module_id.to_string(),
5080            reason: reason.to_string(),
5081        }));
5082    }
5083}
5084
5085fn schedule_respawn(
5086    runtime: &SupervisorRuntimeConfig,
5087    snapshot: &SharedSnapshot,
5088    module_id: &str,
5089    delay: Duration,
5090    kind: RespawnKind,
5091) -> Result<(), SuperviseError> {
5092    cancel_deferred_reload(runtime, module_id, "respawn superseded by another restart");
5093    update_snapshot(snapshot, Some(module_id), |state| {
5094        state.respawn_pending = true
5095    })?;
5096    *runtime
5097        .scheduled_respawn
5098        .lock()
5099        .unwrap_or_else(|p| p.into_inner()) = Some(PendingRespawn {
5100        deadline: Instant::now() + delay,
5101        kind,
5102    });
5103    Ok(())
5104}
5105
5106fn record_health_action(snapshot: &SharedSnapshot, module_id: &str, action: String, now_ms: u64) {
5107    let _ = update_snapshot(snapshot, Some(module_id), |state| {
5108        state.health.last_action = Some(action);
5109        state.health.last_action_ms = Some(now_ms);
5110    });
5111}
5112
5113fn supervisor_health_status(status: HealthStatus) -> SupervisorHealthStatus {
5114    match status {
5115        HealthStatus::Ok => SupervisorHealthStatus::Ok,
5116        HealthStatus::Degraded => SupervisorHealthStatus::Degraded,
5117        HealthStatus::Failing => SupervisorHealthStatus::Failing,
5118    }
5119}
5120
5121/// Caps the metrics blob stored in the cached supervisor snapshot, which is
5122/// returned to every `supervisor.list` and `supervisor.health` caller.
5123///
5124/// This cap is deliberately NOT applied on the one-shot `supervisor.health_probe`
5125/// path: that request exists to return a module's complete metrics object, and
5126/// `ck health <module-id>` documents it as the way to see what the cached view
5127/// truncates. The asymmetry is the feature.
5128///
5129/// So a new caller must decide which side it is on rather than assume the cap is
5130/// universal. Reaching for it on a fresh-probe path would silently reintroduce
5131/// the truncation that path exists to avoid.
5132fn truncate_health_metrics(metrics: Option<Value>) -> Option<Value> {
5133    let metrics = metrics?;
5134    match serde_json::to_vec(&metrics) {
5135        Ok(encoded) if encoded.len() > MAX_HEALTH_METRICS_BYTES => Some(serde_json::json!({
5136            "truncated": true,
5137            "original_bytes": encoded.len(),
5138        })),
5139        Ok(_) | Err(_) => Some(metrics),
5140    }
5141}
5142
5143/// Spread health probes so a fleet-wide restart does not converge them.
5144///
5145/// The delay is derived from the module id and probe index rather than a random
5146/// source, so it is deterministic per module: a module keeps its own offset
5147/// across daemon restarts instead of re-rolling into a collision.
5148fn jittered_health_delay(module_id: &str, probe_index: u64, cadence: Duration) -> Duration {
5149    if cadence.is_zero() {
5150        return Duration::ZERO;
5151    }
5152    let cadence_ms = cadence.as_millis() as u64;
5153    // This early return is REDUNDANT, deliberately, and a mutation run will show
5154    // it surviving removal. Recording why here so the next person to notice does
5155    // not have to re-derive it:
5156    //
5157    // - It is unreachable in practice. `positive_millis` in daemon_config rejects
5158    //   a zero cadence and builds the Duration from whole milliseconds, so a
5159    //   sub-millisecond cadence cannot come from config.
5160    // - Even if reached it changes no answer. The `.max(1)` below makes the span
5161    //   1, and `hash % 1` is 0, so the fall-through returns `cadence` unchanged
5162    //   -- exactly what this returns.
5163    //
5164    // Kept as a guard against a future widening of the config parser (accepting
5165    // microseconds, say), which would make the sub-millisecond case reachable.
5166    // The `.max(1)` is the load-bearing half TODAY: remove it and the modulo
5167    // divides by zero. Remove this and nothing changes.
5168    if cadence_ms == 0 {
5169        return cadence;
5170    }
5171    // Note that this never returns less than one cadence, including for the FIRST
5172    // probe. So a freshly registered module reports health `unknown` for a full
5173    // cadence plus jitter -- 30-33s at the default -- no matter how quickly it is
5174    // ready to answer.
5175    //
5176    // That is a property of the supervisor's schedule, not of any module: an
5177    // operator watching a restart sees `unknown` and cannot tell it from a module
5178    // that is slow to warm. Measured on two unrelated modules, both flipping to
5179    // `ok` between 22s and 32s after restart.
5180    //
5181    // Left as-is because spreading the first probe is what keeps a fleet-wide
5182    // restart from firing fourteen simultaneous probes into a cold machine. The
5183    // alternative -- probe at t+0 and jitter only from the second onward -- trades
5184    // that thundering herd for a faster first reading.
5185    let jitter_span = (cadence_ms / 10).max(1);
5186    let hash = module_id.as_bytes().iter().fold(
5187        probe_index.wrapping_mul(0x9E37_79B9_7F4A_7C15),
5188        |acc, byte| {
5189            acc.wrapping_mul(1099511628211)
5190                .wrapping_add(u64::from(*byte))
5191        },
5192    );
5193    cadence + Duration::from_millis(hash % jitter_span)
5194}
5195
5196#[cfg(test)]
5197mod tests {
5198    use super::*;
5199
5200    #[test]
5201    fn readding_a_module_clears_its_rescan_removal_tombstone() {
5202        let handle = SupervisorHandle::new();
5203        let module_id = "readded-tombstone";
5204        handle.record_rescan_removal(module_id);
5205        assert!(handle.removal_tombstone_age_ms(module_id).is_some());
5206
5207        handle.apply_identity_configuration(&ModuleSpec {
5208            module_id: module_id.to_string(),
5209            program: PathBuf::from("/test/module"),
5210            args: Vec::new(),
5211            env: Vec::new(),
5212            reserved: false,
5213            reserved_prefixes: Vec::new(),
5214            protocol: ModuleProtocol::Subc,
5215            overlap: Default::default(),
5216        });
5217
5218        assert!(
5219            handle.removal_tombstone_age_ms(module_id).is_none(),
5220            "a re-added module must not retain a stale removal tombstone"
5221        );
5222    }
5223
5224    /// What one module's owner looked like from the control plane at the
5225    /// instant after its first process was spawned.
5226    #[derive(Debug, PartialEq, Eq)]
5227    struct OwnerInSpawnWindow {
5228        module_id: String,
5229        configured: bool,
5230        on_roster: bool,
5231        admission_refusal: Option<&'static str>,
5232    }
5233
5234    /// A supervised module's process can connect, register, sync its scopes
5235    /// and describe them as soon as it is spawned, which is BEFORE the
5236    /// supervisor puts the module on the roster. In that window the owner must
5237    /// already read as configured, so a scoped `route.open` against it is
5238    /// refused as retryable `scope_not_synced` and not as terminal
5239    /// `scope_not_live` ("will never sync").
5240    ///
5241    /// The hook runs in exactly that window on every path that takes on a new
5242    /// module, so no race with a real child is needed: `on_roster: false`
5243    /// proves each observation was taken before the roster insert.
5244    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5245    async fn a_new_module_is_configured_before_its_first_process_can_register() {
5246        use crate::scopes::ScopeTable;
5247        use subc_protocol::{error_codes, scope::ScopeSelector, Principal};
5248
5249        let dir = cortexkit_test_support::ScratchDir::new("configured-before-spawn");
5250        let stub = |module_id: &str, program: PathBuf| ModuleSpec {
5251            module_id: module_id.to_string(),
5252            program,
5253            args: Vec::new(),
5254            env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
5255                .into_iter()
5256                .map(|key| (key.to_string(), dir.path().display().to_string()))
5257                .chain([("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string())])
5258                .collect(),
5259            reserved: false,
5260            reserved_prefixes: Vec::new(),
5261            protocol: ModuleProtocol::Subc,
5262            overlap: Default::default(),
5263        };
5264        let live = super::terminal_history_tests::fake_aft_stub_path();
5265        let missing = dir.path().join("definitely-missing-module");
5266
5267        let handle = SupervisorHandle::new();
5268        let mut supervisor =
5269            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5270                .with_handle(handle.clone());
5271        let observed = Arc::new(Mutex::new(Vec::<OwnerInSpawnWindow>::new()));
5272        let hook_handle = handle.clone();
5273        let hook_observed = Arc::clone(&observed);
5274        supervisor.test_after_first_spawn = AfterFirstSpawnHook(Some(Arc::new(move |module_id| {
5275            // Exactly what the control plane computes for a scoped route.open
5276            // naming this module as the owner of a scope it has not synced.
5277            let configured = hook_handle.is_configured(module_id);
5278            let selector = ScopeSelector {
5279                owner: Principal::Reserved {
5280                    module_id: module_id.to_string(),
5281                },
5282                scope_ref: "s".to_string(),
5283                scope_epoch: Some(1),
5284            };
5285            let carrier = Principal::Reserved {
5286                module_id: "carrier".to_string(),
5287            };
5288            let admission_refusal = match ScopeTable::new(Vec::<String>::new())
5289                .admit(&carrier, module_id, &selector, configured)
5290            {
5291                Ok(_) => None,
5292                Err(refusal) => Some(refusal.code),
5293            };
5294            hook_observed.lock().unwrap().push(OwnerInSpawnWindow {
5295                module_id: module_id.to_string(),
5296                configured,
5297                on_roster: hook_handle.get(module_id).is_some(),
5298                admission_refusal,
5299            });
5300        })));
5301
5302        let plain = supervisor.spawn(stub("plain", live.clone())).unwrap();
5303        let configured = supervisor
5304            .supervise_configured(stub("configured", live.clone()), true)
5305            .unwrap();
5306        let with_health = supervisor
5307            .supervise_configured_with_health(
5308                stub("with-health", live.clone()),
5309                true,
5310                HealthConfig::default(),
5311                None,
5312                RestartPolicy::default(),
5313            )
5314            .unwrap();
5315        // The failed-spawn path still puts the module on the roster (as
5316        // failed), so it is configured throughout.
5317        let failed = supervisor
5318            .supervise_configured_with_health(
5319                stub("failed-spawn", missing.clone()),
5320                true,
5321                HealthConfig::default(),
5322                None,
5323                RestartPolicy::default(),
5324            )
5325            .unwrap();
5326        // A failed plain `spawn` puts nothing on the roster, so its mark is
5327        // taken back once the spawn has failed.
5328        assert!(supervisor.spawn(stub("spawn-error", missing)).is_err());
5329
5330        let expected = [
5331            "plain",
5332            "configured",
5333            "with-health",
5334            "failed-spawn",
5335            "spawn-error",
5336        ]
5337        .into_iter()
5338        .map(|module_id| OwnerInSpawnWindow {
5339            module_id: module_id.to_string(),
5340            configured: true,
5341            on_roster: false,
5342            admission_refusal: Some(error_codes::SCOPE_NOT_SYNCED),
5343        })
5344        .collect::<Vec<_>>();
5345        assert_eq!(*observed.lock().unwrap(), expected);
5346
5347        for module_id in ["plain", "configured", "with-health", "failed-spawn"] {
5348            assert!(
5349                handle.get(module_id).is_some(),
5350                "{module_id} is on the roster"
5351            );
5352            assert!(
5353                handle.is_configured(module_id),
5354                "{module_id} stays configured"
5355            );
5356        }
5357        assert!(handle.get("spawn-error").is_none());
5358        assert!(
5359            !handle.is_configured("spawn-error"),
5360            "a plain spawn that failed must not leave its module marked configured"
5361        );
5362
5363        // Leaving the roster clears the mark with it.
5364        handle.retire("failed-spawn");
5365        assert!(!handle.is_configured("failed-spawn"));
5366
5367        for module in [plain, configured, with_health] {
5368            module.stop().await.unwrap();
5369        }
5370        drop(failed);
5371    }
5372
5373    fn stale_process_snapshot(state: ModuleState, enabled: bool) -> SharedSnapshot {
5374        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(state, enabled)));
5375        update_snapshot(&snapshot, Some("stale-process-facts"), |snapshot| {
5376            snapshot.process_alive = true;
5377            snapshot.pid = Some(41);
5378            snapshot.spawned_at_ms = Some(42);
5379            snapshot.spawned_from = Some(PathBuf::from("/spawned/module"));
5380            snapshot.spawned_file_identity = Some(SpawnedFileIdentity {
5381                device: 43,
5382                inode: 44,
5383                #[cfg(windows)]
5384                windows_image: None,
5385            });
5386        })
5387        .unwrap();
5388        snapshot
5389    }
5390
5391    fn assert_snapshot_process_facts_cleared(snapshot: &SharedSnapshot) {
5392        let snapshot = lock_snapshot(snapshot).unwrap();
5393        assert!(!snapshot.process_alive);
5394        assert_eq!(snapshot.pid, None);
5395        assert_eq!(snapshot.spawned_at_ms, None);
5396        assert_eq!(snapshot.spawned_from, None);
5397        assert_eq!(snapshot.spawned_file_identity, None);
5398    }
5399
5400    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5401    async fn failed_enable_spawn_clears_preexisting_current_process_facts() {
5402        let supervisor =
5403            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5404        let mut runtime = supervisor.runtime_config();
5405        runtime.test_seed_stale_facts_before_enable_spawn = true;
5406        let snapshot = stale_process_snapshot(ModuleState::Disabled, false);
5407        let mut child = None;
5408        let spec = ModuleSpec {
5409            module_id: "failed-enable-clears-facts".to_string(),
5410            program: PathBuf::from("/definitely/missing/failed-enable-module"),
5411            args: Vec::new(),
5412            env: Vec::new(),
5413            reserved: false,
5414            reserved_prefixes: Vec::new(),
5415            protocol: ModuleProtocol::Subc,
5416            overlap: Default::default(),
5417        };
5418
5419        let result = set_child_enabled(
5420            &spec,
5421            &runtime,
5422            &supervisor.registry,
5423            &supervisor.process_liveness,
5424            &snapshot,
5425            &mut child,
5426            true,
5427        )
5428        .await;
5429
5430        assert!(matches!(result, Err(SuperviseError::Spawn { .. })));
5431        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
5432        assert_snapshot_process_facts_cleared(&snapshot);
5433    }
5434
5435    #[tokio::test]
5436    async fn start_revives_stranded_restarting_but_not_pending_backoff() {
5437        let supervisor =
5438            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5439        let runtime = supervisor.runtime_config();
5440        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(
5441            ModuleState::Restarting,
5442            true,
5443        )));
5444        let spec = ModuleSpec {
5445            module_id: "start-stranded-restarting".to_string(),
5446            program: super::terminal_history_tests::fake_aft_stub_path(),
5447            args: Vec::new(),
5448            env: vec![("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string())],
5449            reserved: false,
5450            reserved_prefixes: Vec::new(),
5451            protocol: ModuleProtocol::None,
5452            overlap: Default::default(),
5453        };
5454        let mut child = None;
5455        lock_snapshot(&snapshot).unwrap().respawn_pending = true;
5456        assert!(!super::set_child_enabled(
5457            &spec,
5458            &runtime,
5459            &Registry::default(),
5460            &supervisor.process_liveness,
5461            &snapshot,
5462            &mut child,
5463            true
5464        )
5465        .await
5466        .unwrap());
5467        assert!(child.is_none());
5468        lock_snapshot(&snapshot).unwrap().respawn_pending = false;
5469        assert!(super::set_child_enabled(
5470            &spec,
5471            &runtime,
5472            &Registry::default(),
5473            &supervisor.process_liveness,
5474            &snapshot,
5475            &mut child,
5476            true
5477        )
5478        .await
5479        .unwrap());
5480        assert_eq!(
5481            lock_snapshot(&snapshot).unwrap().state,
5482            ModuleState::Running
5483        );
5484        let mut child = child.unwrap();
5485        child.start_kill().unwrap();
5486        child.wait().await.unwrap();
5487    }
5488
5489    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5490    async fn failed_reload_spawn_clears_current_process_facts() {
5491        let supervisor =
5492            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5493        let mut runtime = supervisor.runtime_config();
5494        runtime.restart_policy = RestartPolicy::new(0, Duration::ZERO);
5495        let snapshot = stale_process_snapshot(ModuleState::Running, true);
5496        let mut child = None;
5497        let spec = ModuleSpec {
5498            module_id: "failed-reload-clears-facts".to_string(),
5499            program: PathBuf::from("/unused/failed-reload-module"),
5500            args: Vec::new(),
5501            env: Vec::new(),
5502            reserved: false,
5503            reserved_prefixes: Vec::new(),
5504            protocol: ModuleProtocol::Subc,
5505            overlap: Default::default(),
5506        };
5507
5508        let result = handle_reload_spawn_failure(
5509            &spec,
5510            &runtime,
5511            &supervisor.process_liveness,
5512            &snapshot,
5513            &mut child,
5514            "forced reload spawn failure".to_string(),
5515        )
5516        .await;
5517
5518        assert!(matches!(result, Err(SuperviseError::ReloadFailed { .. })));
5519        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
5520        assert_snapshot_process_facts_cleared(&snapshot);
5521    }
5522
5523    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5524    async fn dropping_a_module_with_an_active_monitor_clears_current_process_facts() {
5525        let supervisor =
5526            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5527        let snapshot = stale_process_snapshot(ModuleState::Running, true);
5528        let module = supervisor.supervised_module(
5529            ModuleSpec {
5530                module_id: "drop-clears-facts".to_string(),
5531                program: PathBuf::from("/unused/drop-module"),
5532                args: Vec::new(),
5533                env: Vec::new(),
5534                reserved: false,
5535                reserved_prefixes: Vec::new(),
5536                protocol: ModuleProtocol::Subc,
5537                overlap: Default::default(),
5538            },
5539            supervisor.runtime_config(),
5540            Arc::clone(&snapshot),
5541            None,
5542        );
5543        assert!(!module
5544            .inner
5545            .monitor
5546            .lock()
5547            .unwrap()
5548            .as_ref()
5549            .unwrap()
5550            .is_finished());
5551
5552        drop(module);
5553
5554        assert_eq!(
5555            lock_snapshot(&snapshot).unwrap().state,
5556            ModuleState::Stopped
5557        );
5558        assert_snapshot_process_facts_cleared(&snapshot);
5559    }
5560
5561    #[cfg(unix)]
5562    #[tokio::test]
5563    async fn rescan_preserves_running_protocol_until_respawn() {
5564        let dir = cortexkit_test_support::ScratchDir::new("rescan-protocol");
5565        let initial = ModuleSpec {
5566            module_id: "rescan-protocol".into(),
5567            program: PathBuf::from("/bin/sleep"),
5568            args: vec!["60".into()],
5569            env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
5570                .into_iter()
5571                .map(|key| (key.into(), dir.path().to_string_lossy().into_owned()))
5572                .collect(),
5573            reserved: false,
5574            reserved_prefixes: vec![],
5575            protocol: ModuleProtocol::None,
5576            overlap: Default::default(),
5577        };
5578        let supervisor =
5579            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5580        let module = supervisor.spawn(initial.clone()).unwrap();
5581        assert!(module.status().unwrap().live);
5582        let mut next = initial;
5583        next.protocol = ModuleProtocol::Subc;
5584        module
5585            .update_configuration(next.clone(), HealthConfig::default(), None)
5586            .await
5587            .unwrap();
5588        assert!(
5589            module.status().unwrap().live,
5590            "rescan must not require HELLO from the old non-wire process"
5591        );
5592        let runtime = supervisor.runtime_config();
5593        let action = on_child_exit(
5594            &next,
5595            RestartPolicy::default(),
5596            &supervisor.registry,
5597            &module.inner.snapshot,
5598            &runtime.terminal_ring,
5599            &runtime.spawn_events,
5600            &runtime.child_roster,
5601            ExitReport {
5602                kind: ExitKind::Clean,
5603                code: Some(0),
5604                signal: None,
5605                at_ms: unix_ms_now(),
5606            },
5607        )
5608        .await;
5609        assert!(
5610            matches!(action, NextAction::Restart { .. }),
5611            "the old non-wire process's clean exit must restart"
5612        );
5613        module.drain().await.unwrap();
5614    }
5615
5616    #[cfg(unix)]
5617    #[tokio::test]
5618    async fn orphan_identity_matches_path_names_and_shebang_interpreters() {
5619        use std::os::unix::fs::PermissionsExt;
5620        let dir = cortexkit_test_support::ScratchDir::new("spawn-image-identity");
5621        let script = dir.join("module.sh");
5622        std::fs::write(&script, "#!/bin/sh\nwhile :; do sleep 0.1; done\n").unwrap();
5623        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700)).unwrap();
5624        let record_path = dir.join("live-children.json");
5625        let supervisor =
5626            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5627                .with_live_children_record(&record_path);
5628        for (program, args) in [
5629            (PathBuf::from("sleep"), vec!["60".into()]),
5630            (script, vec![]),
5631        ] {
5632            let spec = ModuleSpec {
5633                module_id: "image-identity".into(),
5634                program: program.clone(),
5635                args,
5636                env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
5637                    .into_iter()
5638                    .map(|key| (key.into(), dir.to_string_lossy().into_owned()))
5639                    .collect(),
5640                reserved: false,
5641                reserved_prefixes: vec![],
5642                protocol: ModuleProtocol::None,
5643                overlap: Default::default(),
5644            };
5645            let module = supervisor.spawn(spec).unwrap();
5646            #[cfg(target_os = "macos")]
5647            {
5648                // SETEXEC confirmation is asynchronous; the orphan record must
5649                // identify the final image, never the intermediate trampoline.
5650                let deadline = Instant::now() + Duration::from_secs(5);
5651                while crate::live_children::read_record(&record_path)
5652                    .unwrap()
5653                    .iter()
5654                    .all(|entry| entry.executable.is_none())
5655                {
5656                    assert!(Instant::now() < deadline, "module image was not confirmed");
5657                    tokio::time::sleep(Duration::from_millis(5)).await;
5658                }
5659            }
5660            let entry = crate::live_children::read_record(&record_path)
5661                .unwrap()
5662                .pop()
5663                .unwrap();
5664            let observed = subc_os::Process::open(entry.pid)
5665                .unwrap()
5666                .unwrap()
5667                .observe()
5668                .unwrap();
5669            let verdict = crate::live_children::identity_verdict(&entry, entry.pid, &observed);
5670            module.drain().await.unwrap();
5671            assert_eq!(
5672                verdict,
5673                crate::live_children::IdentityVerdict::Matches,
5674                "program {program:?}: recorded {entry:?}, observed {observed:?}"
5675            );
5676        }
5677    }
5678
5679    #[cfg(unix)]
5680    fn http_fixture(
5681        dir: &std::path::Path,
5682        url: &str,
5683        threshold: u32,
5684    ) -> crate::daemon_config::ConfiguredModule {
5685        let path = dir.join("subc.jsonc");
5686        std::fs::write(&path, serde_json::json!({"version": 1, "modules": {"http-process": {
5687            "program": "/bin/sleep", "args": ["60"], "protocol": "none",
5688            "env": {"XDG_DATA_HOME":dir,"XDG_RUNTIME_DIR":dir,"XDG_CONFIG_HOME":dir},
5689            "health": {"http":url,"cadence_ms":20,"deadline_ms":200,"failure_threshold":threshold},
5690            "restart": {"backoff_ms":1,"max_backoff_ms":1}, "drain_timeout_ms":10
5691        }}}).to_string()).unwrap();
5692        crate::daemon_config::load(&path)
5693            .unwrap()
5694            .unwrap()
5695            .modules
5696            .pop()
5697            .unwrap()
5698    }
5699
5700    #[cfg(unix)]
5701    async fn wait_http_health(module: &SupervisedModule, status: SupervisorHealthStatus) {
5702        timeout(Duration::from_secs(5), async {
5703            loop {
5704                if module.status().unwrap().health.status == status {
5705                    break;
5706                }
5707                sleep(Duration::from_millis(5)).await;
5708            }
5709        })
5710        .await
5711        .unwrap_or_else(|_| {
5712            panic!(
5713                "expected {status:?}, got {:?}",
5714                module.status().unwrap().health
5715            )
5716        });
5717    }
5718
5719    #[cfg(unix)]
5720    #[tokio::test]
5721    async fn http_health_status_flips_ok_failing_ok() {
5722        use tokio::io::{AsyncReadExt, AsyncWriteExt};
5723        let dir = cortexkit_test_support::ScratchDir::new("http-status-flips");
5724        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5725        let status = Arc::new(std::sync::atomic::AtomicU16::new(200));
5726        let serving_status = status.clone();
5727        let url = format!("http://{}/healthz", listener.local_addr().unwrap());
5728        let server = tokio::spawn(async move {
5729            loop {
5730                let (mut stream, _) = listener.accept().await.unwrap();
5731                let mut request = [0u8; 2048];
5732                let count = stream.read(&mut request).await.unwrap();
5733                assert!(count > 0, "a probe must send an HTTP request");
5734                let code = serving_status.load(std::sync::atomic::Ordering::SeqCst);
5735                let body = if code == 200 {
5736                    "ready"
5737                } else {
5738                    "{\"status\":\"unavailable\",\"error\":\"scratch failure\"}"
5739                };
5740                let response = format!("HTTP/1.1 {code} scratch\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
5741                let _ = stream.write_all(response.as_bytes()).await;
5742            }
5743        });
5744        let configured = http_fixture(&dir, &url, 1000);
5745        let module =
5746            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5747                .supervise_configured_with_health(
5748                    configured.module_spec(),
5749                    true,
5750                    configured.health,
5751                    configured.drain_timeout_ms,
5752                    configured.restart,
5753                )
5754                .unwrap();
5755        wait_http_health(&module, SupervisorHealthStatus::Ok).await;
5756        status.store(503, std::sync::atomic::Ordering::SeqCst);
5757        wait_http_health(&module, SupervisorHealthStatus::Failing).await;
5758        assert!(module
5759            .status()
5760            .unwrap()
5761            .health
5762            .detail
5763            .unwrap()
5764            .contains("scratch failure"));
5765        status.store(200, std::sync::atomic::Ordering::SeqCst);
5766        wait_http_health(&module, SupervisorHealthStatus::Ok).await;
5767        assert_eq!(module.status().unwrap().health.consecutive_failures, 0);
5768        let before = module.status().unwrap();
5769        let (spec, mut health) = module.configuration().unwrap();
5770        health.http = None;
5771        module
5772            .update_configuration(spec.clone(), health.clone(), Some(10))
5773            .await
5774            .unwrap();
5775        wait_http_health(&module, SupervisorHealthStatus::Unknown).await;
5776        health.http = Some(url);
5777        module
5778            .update_configuration(spec, health, Some(10))
5779            .await
5780            .unwrap();
5781        wait_http_health(&module, SupervisorHealthStatus::Ok).await;
5782        assert_eq!(
5783            module.status().unwrap().pid,
5784            before.pid,
5785            "changing a probe must apply live, not restart its process"
5786        );
5787        let (spec, mut health) = module.configuration().unwrap();
5788        health.failure_threshold = 2;
5789        module
5790            .update_configuration(spec, health, Some(10))
5791            .await
5792            .unwrap();
5793        status.store(503, std::sync::atomic::Ordering::SeqCst);
5794        timeout(Duration::from_secs(5), async {
5795            while module.status().unwrap().spawn_generation == before.spawn_generation {
5796                sleep(Duration::from_millis(5)).await;
5797            }
5798        })
5799        .await
5800        .expect("sustained HTTP 503 responses must trigger the health restart policy");
5801        module.drain().await.unwrap();
5802        server.abort();
5803    }
5804
5805    #[cfg(unix)]
5806    #[tokio::test]
5807    async fn http_health_no_listener_restarts_after_consecutive_failures() {
5808        let dir = cortexkit_test_support::ScratchDir::new("http-refused");
5809        let unused = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5810        let url = format!("http://{}/healthz", unused.local_addr().unwrap());
5811        drop(unused);
5812        let configured = http_fixture(&dir, &url, 2);
5813        let module =
5814            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5815                .supervise_configured_with_health(
5816                    configured.module_spec(),
5817                    true,
5818                    configured.health,
5819                    configured.drain_timeout_ms,
5820                    configured.restart,
5821                )
5822                .unwrap();
5823        let before = module.status().unwrap().spawn_generation;
5824        timeout(Duration::from_secs(5), async {
5825            loop {
5826                let status = module.status().unwrap();
5827                if status.spawn_generation > before {
5828                    assert!(status.lifetime_restarts > 0);
5829                    break;
5830                }
5831                sleep(Duration::from_millis(5)).await;
5832            }
5833        })
5834        .await
5835        .expect("sustained HTTP refusal must trigger the health restart policy");
5836        module.drain().await.unwrap();
5837    }
5838
5839    #[cfg(unix)]
5840    #[tokio::test]
5841    async fn http_health_timeout_honours_deadline() {
5842        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5843        let url = format!("http://{}/healthz", listener.local_addr().unwrap());
5844        let server = tokio::spawn(async move {
5845            let _held = listener.accept().await.unwrap();
5846            std::future::pending::<()>().await;
5847        });
5848        let error = timeout(
5849            Duration::from_secs(1),
5850            probe_http_health(&url, Duration::from_millis(10)),
5851        )
5852        .await
5853        .expect("the probe must enforce its own deadline")
5854        .unwrap_err();
5855        server.abort();
5856        assert!(error.to_string().contains("timed out"));
5857    }
5858
5859    #[cfg(unix)]
5860    #[tokio::test]
5861    async fn http_health_localhost_reaches_an_ipv6_only_listener() {
5862        use tokio::io::{AsyncReadExt, AsyncWriteExt};
5863        let listener = tokio::net::TcpListener::bind("[::1]:0").await.unwrap();
5864        let url = format!(
5865            "http://localhost:{}/healthz",
5866            listener.local_addr().unwrap().port()
5867        );
5868        let server = tokio::spawn(async move {
5869            let (mut stream, _) = listener.accept().await.unwrap();
5870            let mut request = [0u8; 2048];
5871            assert!(stream.read(&mut request).await.unwrap() > 0);
5872            stream
5873                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
5874                .await
5875                .unwrap();
5876        });
5877        // The deadline only bounds a hang. A probe that never tried the IPv6
5878        // address would be refused on 127.0.0.1 and fail at once, so a longer
5879        // deadline does not weaken the assertion; one second timed out under a
5880        // loaded parallel test run.
5881        assert_eq!(
5882            probe_http_health(&url, Duration::from_secs(10))
5883                .await
5884                .unwrap()
5885                .status,
5886            HealthStatus::Ok
5887        );
5888        server.await.unwrap();
5889    }
5890
5891    #[cfg(unix)]
5892    #[tokio::test]
5893    async fn http_health_timeout_keeps_partial_status_and_body() {
5894        use tokio::io::{AsyncReadExt, AsyncWriteExt};
5895        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5896        let url = format!("http://{}/healthz", listener.local_addr().unwrap());
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(
5903                    b"HTTP/1.1 503 Unavailable\r\nContent-Length: 100\r\n\r\npartial diagnostic",
5904                )
5905                .await
5906                .unwrap();
5907            std::future::pending::<()>().await;
5908        });
5909        let error = probe_http_health(&url, Duration::from_secs(1))
5910            .await
5911            .unwrap_err()
5912            .to_string();
5913        server.abort();
5914        assert!(
5915            error.contains("timed out")
5916                && error.contains("503 Unavailable")
5917                && error.contains("partial diagnostic"),
5918            "{error}"
5919        );
5920    }
5921
5922    #[cfg(unix)]
5923    #[tokio::test]
5924    async fn http_health_diagnostic_body_is_bounded_to_200_bytes() {
5925        use tokio::io::{AsyncReadExt, AsyncWriteExt};
5926        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5927        let url = format!("http://{}/healthz", listener.local_addr().unwrap());
5928        let server = tokio::spawn(async move {
5929            let (mut stream, _) = listener.accept().await.unwrap();
5930            let mut request = [0u8; 2048];
5931            assert!(stream.read(&mut request).await.unwrap() > 0);
5932            let body = format!("{}not-in-diagnostic", "x".repeat(400));
5933            let response = format!(
5934                "HTTP/1.1 503 Unavailable\r\nContent-Length: {}\r\n\r\n{body}",
5935                body.len()
5936            );
5937            stream.write_all(response.as_bytes()).await.unwrap();
5938        });
5939        let error = probe_http_health(&url, Duration::from_secs(1))
5940            .await
5941            .unwrap_err()
5942            .to_string();
5943        server.await.unwrap();
5944        assert!(error.ends_with(&"x".repeat(200)), "{error}");
5945        assert_eq!(error.split(": ").last().unwrap().len(), 200);
5946        assert!(!error.contains("not-in-diagnostic"));
5947    }
5948
5949    #[cfg(unix)]
5950    #[tokio::test]
5951    async fn http_health_real_nats_server_monitoring() {
5952        if std::process::Command::new("nats-server")
5953            .arg("--version")
5954            .env("XDG_DATA_HOME", std::env::temp_dir())
5955            .env("XDG_RUNTIME_DIR", std::env::temp_dir())
5956            .env("XDG_CONFIG_HOME", std::env::temp_dir())
5957            .output()
5958            .is_err()
5959        {
5960            eprintln!(
5961                "skipped: http_health_real_nats_server_monitoring (nats-server is not on PATH)"
5962            );
5963            return;
5964        }
5965        let dir = cortexkit_test_support::ScratchDir::new("nats-http-monitoring");
5966        let monitor = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5967        let port = monitor.local_addr().unwrap().port();
5968        let client = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5969        let client_port = client.local_addr().unwrap().port();
5970        let config = dir.join("server.conf");
5971        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();
5972        let mut configured = http_fixture(&dir, &format!("http://127.0.0.1:{port}/healthz"), 1000);
5973        configured.program = PathBuf::from("nats-server");
5974        configured.args = vec!["-c".into(), config.to_string_lossy().into_owned()];
5975        drop(monitor);
5976        drop(client);
5977        let module =
5978            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5979                .supervise_configured_with_health(
5980                    configured.module_spec(),
5981                    true,
5982                    configured.health,
5983                    configured.drain_timeout_ms,
5984                    configured.restart,
5985                )
5986                .unwrap();
5987        wait_http_health(&module, SupervisorHealthStatus::Ok).await;
5988        module.drain().await.unwrap();
5989    }
5990
5991    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5992    async fn configuration_update_does_not_replace_captured_running_process_facts() {
5993        let supervisor =
5994            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5995        let snapshot = stale_process_snapshot(ModuleState::Running, true);
5996        let initial = ModuleSpec {
5997            module_id: "rescan-preserves-spawn-facts".to_string(),
5998            program: PathBuf::from("/spawned/module"),
5999            args: Vec::new(),
6000            env: Vec::new(),
6001            reserved: false,
6002            reserved_prefixes: Vec::new(),
6003            protocol: ModuleProtocol::Subc,
6004            overlap: Default::default(),
6005        };
6006        let module = supervisor.supervised_module(
6007            initial.clone(),
6008            supervisor.runtime_config(),
6009            snapshot,
6010            None,
6011        );
6012        let before = module.status().unwrap();
6013        let mut replacement = initial;
6014        replacement.program = PathBuf::from("/rescanned/replacement-module");
6015
6016        module
6017            .update_configuration(replacement, HealthConfig::default(), None)
6018            .await
6019            .unwrap();
6020
6021        let after = module.status().unwrap();
6022        assert_eq!(after.pid, before.pid);
6023        assert_eq!(after.spawned_at_ms, before.spawned_at_ms);
6024        assert_eq!(after.spawned_from, before.spawned_from);
6025        drop(module);
6026    }
6027}
6028
6029fn unix_ms_now() -> u64 {
6030    SystemTime::now()
6031        .duration_since(UNIX_EPOCH)
6032        .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
6033        .unwrap_or(0)
6034}
6035
6036async fn supervise_loop(
6037    mut spec: ModuleSpec,
6038    mut runtime: SupervisorRuntimeConfig,
6039    registry: Arc<Registry>,
6040    process_liveness: Arc<SupervisorProcessLiveness>,
6041    snapshot: SharedSnapshot,
6042    mut child: Option<SupervisedChild>,
6043    mut commands: mpsc::Receiver<SupervisorCommand>,
6044) {
6045    let mut health_probe = HealthProbeRuntime::default();
6046    // Registry writes (including embedded callers) notify this module only.
6047    // Subscribe before the first refresh; watch retains changes that arrive
6048    // while commands or probes are running. No polling fallback is needed.
6049    let mut registration_changes = match registry.subscribe_module_changes(&spec.module_id) {
6050        Ok(changes) => Some(changes),
6051        Err(err) => {
6052            // A poisoned registry cannot accept further writes, so it cannot
6053            // recover via a timer. Keep exit and command handling alive.
6054            warn!(module_id = %spec.module_id, error = %err, "health prober could not subscribe to registry");
6055            None
6056        }
6057    };
6058    // All restart backoffs run here, including health and operator requests.
6059    // While one is pending the loop serves commands, so disable or drain can
6060    // cancel the replacement without spawning a process just to stop it.
6061    let mut pending_respawn: Option<PendingRespawn> = None;
6062    // Commands a swap handed back to run next (see `swap::SwapEnd`). Served
6063    // before anything else so a stop that interrupted a swap runs at once.
6064    let mut requeued: VecDeque<SupervisorCommand> = VecDeque::new();
6065    loop {
6066        #[cfg(test)]
6067        {
6068            lock_snapshot(&snapshot).unwrap().actor_select = None;
6069        }
6070        #[cfg(target_os = "macos")]
6071        if let Some(active) = child.as_mut() {
6072            active.confirm_privacy_exec().await;
6073        }
6074        if let Some(scheduled) = runtime
6075            .scheduled_respawn
6076            .lock()
6077            .unwrap_or_else(|p| p.into_inner())
6078            .take()
6079        {
6080            pending_respawn = Some(scheduled);
6081        }
6082        if pending_respawn.is_some() && (child.is_some() || !respawn_still_pending(&snapshot)) {
6083            pending_respawn = None;
6084            cancel_deferred_reload(
6085                &runtime,
6086                &spec.module_id,
6087                "respawn cancelled by a supervisor command",
6088            );
6089        }
6090        if child.is_none() && pending_respawn.is_none() {
6091            cancel_deferred_reload(
6092                &runtime,
6093                &spec.module_id,
6094                "respawn cancelled before a replacement was spawned",
6095            );
6096            let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6097                state.respawn_pending = false;
6098                state.coalesced_restart_pending = false;
6099                if matches!(
6100                    state.state,
6101                    ModuleState::Restarting
6102                        | ModuleState::Starting
6103                        | ModuleState::Draining
6104                        | ModuleState::Unresponsive
6105                ) {
6106                    error!(module_id = %spec.module_id, state = ?state.state,
6107                        "supervision operation ended without a child or pending respawn; marking failed so start can retry");
6108                    state.state = ModuleState::Failed;
6109                    clear_current_process_facts(state);
6110                }
6111            });
6112        }
6113        if let Some(command) = requeued.pop_front() {
6114            if !handle_supervisor_command(
6115                command,
6116                &mut spec,
6117                &mut runtime,
6118                &registry,
6119                &process_liveness,
6120                &snapshot,
6121                &mut child,
6122                &mut commands,
6123                &mut requeued,
6124            )
6125            .await
6126            {
6127                return;
6128            }
6129            if child.is_some() || !respawn_still_pending(&snapshot) {
6130                pending_respawn = None;
6131                let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6132                    state.respawn_pending = false
6133                });
6134            }
6135            continue;
6136        }
6137        if child.is_some() {
6138            #[cfg(test)]
6139            {
6140                lock_snapshot(&snapshot).unwrap().actor_turns += 1;
6141            }
6142            health_probe.refresh_registration(&spec, &runtime, &registry, &snapshot);
6143            let wake_after = health_probe.wake_after();
6144            let probe_sleep = async move {
6145                match wake_after {
6146                    Some(delay) => sleep(delay).await,
6147                    None => std::future::pending().await,
6148                }
6149            };
6150            tokio::pin!(probe_sleep);
6151            // A `protocol: "none"` child (a plain process that never registers
6152            // over the subc wire, such as nats-server) with no HTTP health
6153            // check has nothing to probe, so it waits only for its exit or a
6154            // supervisor command. A rescan that adds an HTTP check is a command.
6155            let wire_child = running_protocol(&spec, &snapshot) == ModuleProtocol::Subc;
6156            #[cfg(test)]
6157            {
6158                let registration_pending = wire_child
6159                    && registration_changes
6160                        .as_ref()
6161                        .is_some_and(|changes| changes.has_changed().unwrap());
6162                let mut state = lock_snapshot(&snapshot).unwrap();
6163                state.actor_select = (!registration_pending).then_some(ActorSelectCheckpoint {
6164                    generation: state.spawn_generation,
6165                    turn: state.actor_turns,
6166                    registered_connection: health_probe.registered_connection,
6167                    next_probe_at: health_probe.next_probe_at,
6168                    wake_after,
6169                });
6170            }
6171            let active_child = child.as_mut().expect("child checked above");
6172            tokio::select! {
6173                wait_result = active_child.wait() => {
6174                    #[cfg(test)]
6175                    { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6176                    // Every arm below that gives up on the CHILD must keep the
6177                    // supervision task itself alive (child = None, loop
6178                    // continues into command-serving mode). Returning here
6179                    // closes the command channel, which makes the module
6180                    // permanently unrestartable in-band: a clean child exit
6181                    // of an enabled module once wedged the fleet this way
6182                    // ('supervisor command channel is closed') and required a
6183                    // full daemon restart to recover.
6184                    let exit_report = match wait_result {
6185                        Ok(status) => classify_reaped_child_exit(&snapshot, active_child, &status),
6186                        Err(err) => {
6187                            active_child.drain_stderr(&spec.module_id).await;
6188                            fail_after_wait_error(
6189                                &spec.module_id,
6190                                &snapshot,
6191                                &runtime.terminal_ring,
6192                                &runtime.spawn_events,
6193                                format!("failed to wait for supervised module: {err}"),
6194                            );
6195                            untrack_if_registration_released(
6196                                &process_liveness,
6197                                &registry,
6198                                &spec.module_id,
6199                                &snapshot,
6200                            );
6201                            error!(module_id = %spec.module_id, error = %err, "failed to wait for supervised module");
6202                            child = None;
6203                            continue;
6204                        }
6205                    };
6206                    active_child.drain_stderr(&spec.module_id).await;
6207
6208                    let next = on_child_exit(
6209                        &spec,
6210                        runtime.restart_policy,
6211                        &registry,
6212                        &snapshot,
6213                        &runtime.terminal_ring,
6214                        &runtime.spawn_events,
6215                        &runtime.child_roster,
6216                        exit_report,
6217                    ).await;
6218                    // The exit is recorded, so a daemon shutdown may stop
6219                    // waiting for this child (see `SupervisedChild::wait`).
6220                    active_child.release_roster();
6221                    match next {
6222                        NextAction::Stop { registration_released } => {
6223                            if registration_released {
6224                                process_liveness.untrack_if_current(&spec.module_id, &snapshot);
6225                            }
6226                            child = None;
6227                        }
6228                        NextAction::Restart { schedule } => {
6229                            let delay = schedule.map_or(
6230                                runtime.restart_policy.delay_for_restart(0),
6231                                |schedule| schedule.delay,
6232                            );
6233                            if let Some(schedule) = schedule {
6234                                log_crash_respawn(&spec.module_id, schedule);
6235                            }
6236                            // The exited child is fully recorded at this point,
6237                            // so release it and count the backoff down in the
6238                            // command-serving branch below rather than sleeping
6239                            // here: commands cannot be received from inside this
6240                            // select arm, and an operator disable or drain that
6241                            // arrives during the backoff must cancel the pending
6242                            // respawn instead of waiting for it to spawn first.
6243                            child = None;
6244                            pending_respawn = Some(PendingRespawn { deadline: Instant::now() + delay, kind: RespawnKind::Spawn });
6245                             let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| state.respawn_pending = true);
6246                        }
6247                    }
6248                }
6249                command = commands.recv() => {
6250                    #[cfg(test)]
6251                    { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6252                    let Some(command) = command else {
6253                        return;
6254                    };
6255                    if !handle_supervisor_command(
6256                        command,
6257                        &mut spec,
6258                        &mut runtime,
6259                        &registry,
6260                        &process_liveness,
6261                        &snapshot,
6262                        &mut child,
6263                        &mut commands,
6264                        &mut requeued,
6265                    ).await {
6266                        return;
6267                    }
6268                }
6269                _ = async {
6270                    match registration_changes.as_mut() {
6271                        Some(changes) => { let _ = changes.changed().await; }
6272                        None => std::future::pending().await,
6273                    }
6274                }, if wire_child => {
6275                    #[cfg(test)]
6276                    { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6277                }
6278                _ = &mut probe_sleep => {
6279                    #[cfg(test)]
6280                    { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6281                    if health_probe.due() {
6282                        run_health_probe_cycle(
6283                            &spec,
6284                            &runtime,
6285                            &registry,
6286                            &process_liveness,
6287                            &snapshot,
6288                            &mut child,
6289                        ).await;
6290                        if child.is_some() {
6291                            health_probe.schedule_next(&spec, runtime.health.cadence);
6292                        }
6293                    }
6294                }
6295            }
6296        } else if let Some(pending) = pending_respawn {
6297            tokio::select! {
6298                _ = sleep_until(pending.deadline) => {
6299                    pending_respawn = None;
6300                let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| state.respawn_pending = false);
6301                    // A command handled below while the backoff elapsed may
6302                    // have stopped the module; never respawn past an operator's
6303                    // disable or drain.
6304                    if !respawn_still_pending(&snapshot) {
6305                        continue;
6306                    }
6307                    // The daemon began shutting down during the backoff: the
6308                    // spawn would be refused anyway, and refusing it here
6309                    // leaves the module stopped instead of reporting a
6310                    // failed restart.
6311                    if runtime.child_roster.is_closed() {
6312                        let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6313                            state.state = ModuleState::Stopped;
6314                        });
6315                        debug!(module_id = %spec.module_id, "crash respawn cancelled by daemon shutdown");
6316                        continue;
6317                    }
6318                    if let Err(err) = release_dead_registration(
6319                        &registry,
6320                        runtime.forwarding.as_deref(),
6321                        &snapshot,
6322                        &spec.module_id,
6323                    ).await {
6324                        fail_snapshot(&snapshot, Some(&spec.module_id), None);
6325                        error!(module_id = %spec.module_id, error = %err, "registration did not release before restart");
6326                        continue;
6327                    }
6328
6329                    if matches!(pending.kind, RespawnKind::Reload) {
6330                        let reply = runtime.deferred_reload_reply.lock().unwrap_or_else(|p| p.into_inner()).take();
6331                        let result = finish_reload_child(&spec, &runtime, &registry, &process_liveness, &snapshot, &mut child).await;
6332                        if let Some(reply) = reply { let _ = reply.send(result); }
6333                        continue;
6334                    }
6335                    process_liveness.track(spec.module_id.clone(), Arc::clone(&snapshot));
6336                    match spawn_and_mark_running(&spec, &runtime, &snapshot) {
6337                        Ok(next_child) => {
6338                            child = Some(next_child);
6339                            debug!(module_id = %spec.module_id, "supervised module restarted after crash");
6340                        }
6341                        Err(err) => {
6342                            fail_snapshot(&snapshot, Some(&spec.module_id), None);
6343                            process_liveness.untrack_if_current(&spec.module_id, &snapshot);
6344                            error!(module_id = %spec.module_id, error = %err, "failed to restart supervised module");
6345                        }
6346                    }
6347                }
6348                command = commands.recv() => {
6349                    let Some(command) = command else {
6350                        return;
6351                    };
6352                    if !handle_supervisor_command(
6353                        command,
6354                        &mut spec,
6355                        &mut runtime,
6356                        &registry,
6357                        &process_liveness,
6358                        &snapshot,
6359                        &mut child,
6360                        &mut commands,
6361                        &mut requeued,
6362                    ).await {
6363                        return;
6364                    }
6365                    // Reconcile the pending respawn with what the command did:
6366                    // a start may already have spawned a fresh child,
6367                    // while a disable or drain moved the snapshot out of the
6368                    // state the respawn was counting down from.
6369                    if child.is_some() || !respawn_still_pending(&snapshot) {
6370                        pending_respawn = None;
6371                let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| state.respawn_pending = false);
6372                    }
6373                }
6374            }
6375        } else {
6376            let Some(command) = commands.recv().await else {
6377                return;
6378            };
6379            if !handle_supervisor_command(
6380                command,
6381                &mut spec,
6382                &mut runtime,
6383                &registry,
6384                &process_liveness,
6385                &snapshot,
6386                &mut child,
6387                &mut commands,
6388                &mut requeued,
6389            )
6390            .await
6391            {
6392                return;
6393            }
6394        }
6395    }
6396}
6397
6398fn log_crash_respawn(module_id: &str, schedule: CrashRestartSchedule) {
6399    info!(
6400        module_id,
6401        restart_in_window = schedule.restart_in_window,
6402        delay_ms = schedule.delay.as_millis() as u64,
6403        "respawning after crash"
6404    );
6405}
6406
6407/// Whether the respawn a backoff was counting down to is still wanted. A
6408/// disable or drain handled while the backoff elapsed moves the snapshot out
6409/// of `Restarting`, and the operator's stop must win over the pending respawn,
6410/// so every sleep-then-spawn path re-validates against the live snapshot
6411/// instead of assuming the state it left behind still holds.
6412fn respawn_still_pending(snapshot: &SharedSnapshot) -> bool {
6413    matches!(
6414        lock_snapshot(snapshot),
6415        Ok(state) if state.enabled && state.state == ModuleState::Restarting
6416    )
6417}
6418
6419enum NextAction {
6420    Stop {
6421        registration_released: bool,
6422    },
6423    Restart {
6424        schedule: Option<CrashRestartSchedule>,
6425    },
6426}
6427
6428#[allow(clippy::too_many_arguments)]
6429async fn handle_supervisor_command(
6430    command: SupervisorCommand,
6431    spec: &mut ModuleSpec,
6432    runtime: &mut SupervisorRuntimeConfig,
6433    registry: &Arc<Registry>,
6434    process_liveness: &SupervisorProcessLiveness,
6435    snapshot: &SharedSnapshot,
6436    child: &mut Option<SupervisedChild>,
6437    commands: &mut mpsc::Receiver<SupervisorCommand>,
6438    requeued: &mut VecDeque<SupervisorCommand>,
6439) -> bool {
6440    match command {
6441        SupervisorCommand::Drain { reply } => {
6442            // A plain stop runs no forwarding drain, so nothing reaches the
6443            // module over its connection before the wait: ask by signal.
6444            let result = drain_optional_child(
6445                &spec.module_id,
6446                spec.protocol,
6447                StopNotice::NotSent,
6448                registry,
6449                runtime.forwarding.as_deref(),
6450                snapshot,
6451                &runtime.terminal_ring,
6452                &runtime.spawn_events,
6453                child,
6454                runtime.drain_timeout,
6455                ModuleState::Stopped,
6456                None,
6457            )
6458            .await;
6459            let registration_released = result.is_ok();
6460            let _ = reply.send(result);
6461            if registration_released {
6462                process_liveness.untrack_if_current(&spec.module_id, snapshot);
6463            }
6464            false
6465        }
6466        SupervisorCommand::Retire { reply } => {
6467            let result = async {
6468                let stop_notice = begin_forwarding_drain_if_configured(
6469                    spec,
6470                    runtime,
6471                    registry,
6472                    snapshot,
6473                    None,
6474                    RouteCloseReason::Disable,
6475                )
6476                .await?;
6477                drain_optional_child(
6478                    &spec.module_id,
6479                    spec.protocol,
6480                    stop_notice,
6481                    registry,
6482                    runtime.forwarding.as_deref(),
6483                    snapshot,
6484                    &runtime.terminal_ring,
6485                    &runtime.spawn_events,
6486                    child,
6487                    runtime.drain_timeout,
6488                    ModuleState::Stopped,
6489                    None,
6490                )
6491                .await
6492            }
6493            .await;
6494            let registration_released = result.is_ok();
6495            let _ = reply.send(result);
6496            if registration_released {
6497                process_liveness.untrack_if_current(&spec.module_id, snapshot);
6498            }
6499            false
6500        }
6501        SupervisorCommand::Restart {
6502            drain_timeout_ms,
6503            received_at_generation,
6504            queued_at,
6505            reply,
6506        } => {
6507            // Without this line a restart that waited in the queue (behind a
6508            // health probe cycle or another command) was invisible: the log
6509            // showed only the drain timing out, minutes after the operator's call.
6510            info!(
6511                module_id = %spec.module_id,
6512                queued_ms = u64::try_from(queued_at.elapsed().as_millis()).unwrap_or(u64::MAX),
6513                "restart command dequeued"
6514            );
6515            // ACK AT INITIATION, not completion. The blocking form deadlocked any
6516            // caller whose own request lane rides the module being restarted: the
6517            // caller's in-flight request keeps the drain from quiescing, the drain
6518            // keeps the restart from completing, and the completion keeps the reply
6519            // from releasing the caller — so the drain always timed out and cut the
6520            // initiator with a GOODBYE, even on a healthy module. Replying once the
6521            // restart is validated lets a self-lane caller settle, which is exactly
6522            // what makes the drain succeed. Completion is observable via
6523            // supervisor.list / module status; a post-ack failure lands the module
6524            // in a visible terminal state below rather than in a reply nobody can
6525            // receive.
6526            let validation = match lock_snapshot(snapshot) {
6527                Ok(state) if !state.enabled => Err(SuperviseError::Disabled {
6528                    module_id: spec.module_id.clone(),
6529                }),
6530                Ok(_) => Ok(()),
6531                Err(err) => Err(err),
6532            };
6533            let initiated = validation.is_ok();
6534            let _ = reply.send(validation);
6535            // A restart asks for a fresh process. Commands run one at a time,
6536            // so a restart queued behind another restart (two operator calls
6537            // in quick succession) is dequeued the moment the first one has
6538            // spawned its replacement -- before that process has sent HELLO.
6539            // Running it would drain and kill the process the first restart
6540            // just produced, which is the opposite of what both callers asked
6541            // for. If a process spawned after this request was received is
6542            // still supervised, the request is already satisfied. Not when the
6543            // configuration changed since that spawn: then the newer process
6544            // predates the spec this restart may exist to apply.
6545            let satisfied_by_generation = if initiated && child.is_some() {
6546                lock_snapshot(snapshot).ok().and_then(|state| {
6547                    (state.spawn_generation > received_at_generation
6548                        && !state.configuration_updated_since_spawn)
6549                        .then_some(state.spawn_generation)
6550                })
6551            } else {
6552                None
6553            };
6554            let satisfied_by_pending = initiated
6555                && child.is_none()
6556                && lock_snapshot(snapshot).ok().is_some_and(|mut state| {
6557                    let pending = state.respawn_pending && !state.configuration_updated_since_spawn;
6558                    if pending {
6559                        state.coalesced_restart_pending = true;
6560                    }
6561                    pending
6562                });
6563            if satisfied_by_pending {
6564                debug!(module_id = %spec.module_id, "restart coalesced into the pending replacement");
6565            } else if let Some(generation) = satisfied_by_generation {
6566                info!(
6567                    module_id = %spec.module_id,
6568                    received_at_generation,
6569                    "restart already satisfied by generation {generation}; not restarting again"
6570                );
6571            } else if initiated {
6572                // Precedence: this restart's operator override, else the module's
6573                // configured budget (already resolved into the runtime).
6574                let drain_timeout = drain_timeout_ms
6575                    .map(Duration::from_millis)
6576                    .unwrap_or(runtime.drain_timeout);
6577                if let Err(err) = restart_child(
6578                    spec,
6579                    runtime,
6580                    registry,
6581                    process_liveness,
6582                    snapshot,
6583                    child,
6584                    drain_timeout,
6585                )
6586                .await
6587                {
6588                    warn!(
6589                        module_id = %spec.module_id,
6590                        error = %err,
6591                        "operator restart failed after initiation ack; module state carries the outcome"
6592                    );
6593                    let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
6594                        state.state = ModuleState::Failed;
6595                        clear_current_process_facts(state);
6596                    });
6597                }
6598            }
6599            true
6600        }
6601        SupervisorCommand::Reload { reply } => {
6602            let result =
6603                reload_child(spec, runtime, registry, process_liveness, snapshot, child).await;
6604            if result.is_ok()
6605                && runtime
6606                    .scheduled_respawn
6607                    .lock()
6608                    .unwrap_or_else(|p| p.into_inner())
6609                    .as_ref()
6610                    .is_some_and(|pending| matches!(pending.kind, RespawnKind::Reload))
6611            {
6612                *runtime
6613                    .deferred_reload_reply
6614                    .lock()
6615                    .unwrap_or_else(|p| p.into_inner()) = Some(reply);
6616            } else {
6617                let _ = reply.send(result);
6618            }
6619            true
6620        }
6621        SupervisorCommand::SetEnabled { enabled, reply } => {
6622            let result = set_child_enabled(
6623                spec,
6624                runtime,
6625                registry,
6626                process_liveness,
6627                snapshot,
6628                child,
6629                enabled,
6630            )
6631            .await;
6632            let _ = reply.send(result);
6633            true
6634        }
6635        SupervisorCommand::UpdateConfiguration {
6636            spec: next_spec,
6637            health,
6638            drain_timeout_ms,
6639            reply,
6640        } => {
6641            if let Some(handle) = &runtime.supervisor_handle {
6642                handle.apply_identity_configuration(&next_spec);
6643            }
6644            *spec = next_spec;
6645            let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
6646                state.configuration_updated_since_spawn = true;
6647            });
6648            let health_changed = runtime.health != health;
6649            runtime.health = health;
6650            // Reset the cadence and old endpoint's failure streak on a live
6651            // health-policy change rather than waiting for its old deadline.
6652            if health_changed {
6653                let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
6654                    state.health = ModuleHealthStatus::default();
6655                });
6656            }
6657            runtime.drain_timeout = drain_timeout_ms
6658                .map(Duration::from_millis)
6659                .unwrap_or(runtime.default_drain_timeout);
6660            *runtime
6661                .effective_drain_timeout
6662                .lock()
6663                .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.drain_timeout;
6664            let _ = reply.send(());
6665            true
6666        }
6667        SupervisorCommand::Swap {
6668            ready_timeout,
6669            reply,
6670        } => {
6671            let end = swap::run_swap(
6672                spec,
6673                runtime,
6674                registry,
6675                process_liveness,
6676                snapshot,
6677                child,
6678                commands,
6679                ready_timeout.unwrap_or(DEFAULT_SWAP_READY_TIMEOUT),
6680                reply,
6681            )
6682            .await;
6683            requeued.extend(end.requeue);
6684            true
6685        }
6686    }
6687}
6688
6689async fn restart_child(
6690    spec: &ModuleSpec,
6691    runtime: &SupervisorRuntimeConfig,
6692    registry: &Registry,
6693    process_liveness: &SupervisorProcessLiveness,
6694    snapshot: &SharedSnapshot,
6695    child: &mut Option<SupervisedChild>,
6696    drain_timeout: Duration,
6697) -> Result<(), SuperviseError> {
6698    // Restart cycles a running module; it must not silently start a disabled one.
6699    if !lock_snapshot(snapshot)?.enabled {
6700        return Err(SuperviseError::Disabled {
6701            module_id: spec.module_id.clone(),
6702        });
6703    }
6704    let stop_notice = begin_forwarding_drain_with_timeout(
6705        spec,
6706        runtime,
6707        registry,
6708        snapshot,
6709        None,
6710        RouteCloseReason::Restart,
6711        drain_timeout,
6712    )
6713    .await?;
6714
6715    if child.is_some() {
6716        drain_optional_child(
6717            &spec.module_id,
6718            spec.protocol,
6719            stop_notice,
6720            registry,
6721            runtime.forwarding.as_deref(),
6722            snapshot,
6723            &runtime.terminal_ring,
6724            &runtime.spawn_events,
6725            child,
6726            drain_timeout,
6727            ModuleState::Restarting,
6728            Some(true),
6729        )
6730        .await?;
6731    } else {
6732        update_snapshot(snapshot, Some(&spec.module_id), |state| {
6733            state.enabled = true;
6734            state.state = ModuleState::Restarting;
6735            clear_current_process_facts(state);
6736        })?;
6737        release_dead_registration(
6738            registry,
6739            runtime.forwarding.as_deref(),
6740            snapshot,
6741            &spec.module_id,
6742        )
6743        .await?;
6744    }
6745
6746    reset_restart_count(snapshot, &spec.module_id)?;
6747    process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
6748    schedule_respawn(
6749        runtime,
6750        snapshot,
6751        &spec.module_id,
6752        runtime.restart_policy.backoff,
6753        RespawnKind::Spawn,
6754    )
6755}
6756
6757async fn reload_child(
6758    spec: &ModuleSpec,
6759    runtime: &SupervisorRuntimeConfig,
6760    registry: &Registry,
6761    process_liveness: &SupervisorProcessLiveness,
6762    snapshot: &SharedSnapshot,
6763    child: &mut Option<SupervisedChild>,
6764) -> Result<(), SuperviseError> {
6765    // Reload cycles a running module; it must not silently start a disabled one.
6766    if !lock_snapshot(snapshot)?.enabled {
6767        return Err(SuperviseError::Disabled {
6768            module_id: spec.module_id.clone(),
6769        });
6770    }
6771    let stop_notice = begin_forwarding_drain(
6772        spec,
6773        runtime,
6774        registry,
6775        snapshot,
6776        Some(true),
6777        RouteCloseReason::Reload,
6778    )
6779    .await?;
6780
6781    if child.is_some() {
6782        drain_optional_child(
6783            &spec.module_id,
6784            spec.protocol,
6785            stop_notice,
6786            registry,
6787            runtime.forwarding.as_deref(),
6788            snapshot,
6789            &runtime.terminal_ring,
6790            &runtime.spawn_events,
6791            child,
6792            runtime.drain_timeout,
6793            ModuleState::Restarting,
6794            Some(true),
6795        )
6796        .await?;
6797    } else {
6798        update_snapshot(snapshot, Some(&spec.module_id), |state| {
6799            state.enabled = true;
6800            state.state = ModuleState::Restarting;
6801            clear_current_process_facts(state);
6802        })?;
6803        release_dead_registration(
6804            registry,
6805            runtime.forwarding.as_deref(),
6806            snapshot,
6807            &spec.module_id,
6808        )
6809        .await?;
6810    }
6811
6812    reset_restart_count(snapshot, &spec.module_id)?;
6813    process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
6814    schedule_respawn(
6815        runtime,
6816        snapshot,
6817        &spec.module_id,
6818        runtime.restart_policy.backoff,
6819        RespawnKind::Reload,
6820    )
6821}
6822
6823async fn finish_reload_child(
6824    spec: &ModuleSpec,
6825    runtime: &SupervisorRuntimeConfig,
6826    registry: &Registry,
6827    process_liveness: &SupervisorProcessLiveness,
6828    snapshot: &SharedSnapshot,
6829    child: &mut Option<SupervisedChild>,
6830) -> Result<(), SuperviseError> {
6831    process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
6832    let next_child = match spawn_and_mark_running(spec, runtime, snapshot) {
6833        Ok(next_child) => next_child,
6834        Err(err) => {
6835            return handle_reload_spawn_failure(
6836                spec,
6837                runtime,
6838                process_liveness,
6839                snapshot,
6840                child,
6841                format!("new child failed to spawn: {err}"),
6842            )
6843            .await;
6844        }
6845    };
6846    *child = Some(next_child);
6847
6848    let wait_outcome = {
6849        let active_child = child.as_mut().expect("new reload child was just stored");
6850        wait_for_registration_after_reload(
6851            registry,
6852            &spec.module_id,
6853            snapshot,
6854            active_child,
6855            REGISTRY_RELEASE_TIMEOUT,
6856        )
6857        .await?
6858    };
6859
6860    match wait_outcome {
6861        RegistrationWaitOutcome::Registered => {
6862            debug!(module_id = %spec.module_id, "supervised module reloaded and registered");
6863            Ok(())
6864        }
6865        RegistrationWaitOutcome::Exited(exit_report) => {
6866            if let Some(active_child) = child.as_mut() {
6867                active_child.drain_stderr(&spec.module_id).await;
6868            }
6869            // Keep the reaped child's roster guard until its terminal is written.
6870            // Shutdown waits on that guard, not on the child Option used for respawn.
6871            let mut exited_child = child.take().expect("exited reload child is still stored");
6872            #[cfg(test)]
6873            if let Some(gate) = &runtime.test_reload_exit_record_gate {
6874                gate.reached.notify_one();
6875                gate.resume.notified().await;
6876            }
6877            let result = handle_reload_child_registration_failure(
6878                spec,
6879                runtime,
6880                registry,
6881                process_liveness,
6882                snapshot,
6883                child,
6884                ReloadRegistrationFailure {
6885                    exit_report: registration_failure_exit_report(exit_report),
6886                    reason: exited_child
6887                        .spawn_failure
6888                        .clone()
6889                        .unwrap_or_else(|| "new child exited before registering".to_string()),
6890                },
6891            )
6892            .await;
6893            exited_child.release_roster();
6894            result
6895        }
6896        RegistrationWaitOutcome::TimedOut => {
6897            let mut timed_out_child = child
6898                .take()
6899                .expect("timed-out reload child is still running");
6900            timed_out_child
6901                .start_kill()
6902                .map_err(|source| SuperviseError::Kill {
6903                    module_id: spec.module_id.clone(),
6904                    source,
6905                })?;
6906            let status = timed_out_child
6907                .wait()
6908                .await
6909                .map_err(|source| SuperviseError::Wait {
6910                    module_id: spec.module_id.clone(),
6911                    source,
6912                })?;
6913            timed_out_child.drain_stderr(&spec.module_id).await;
6914            handle_reload_child_registration_failure(
6915                spec,
6916                runtime,
6917                registry,
6918                process_liveness,
6919                snapshot,
6920                child,
6921                ReloadRegistrationFailure {
6922                    exit_report: registration_failure_exit_report(classify_reaped_child_exit(
6923                        snapshot,
6924                        &timed_out_child,
6925                        &status,
6926                    )),
6927                    reason: format!(
6928                        "new child did not register within {:?}",
6929                        REGISTRY_RELEASE_TIMEOUT
6930                    ),
6931                },
6932            )
6933            .await
6934        }
6935    }
6936}
6937
6938async fn set_child_enabled(
6939    spec: &ModuleSpec,
6940    runtime: &SupervisorRuntimeConfig,
6941    registry: &Registry,
6942    process_liveness: &SupervisorProcessLiveness,
6943    snapshot: &SharedSnapshot,
6944    child: &mut Option<SupervisedChild>,
6945    enabled: bool,
6946) -> Result<bool, SuperviseError> {
6947    let (current_enabled, current_state, respawn_pending) = {
6948        let state = lock_snapshot(snapshot)?;
6949        (state.enabled, state.state, state.respawn_pending)
6950    };
6951    // `start` (enable on an already-enabled module) heals TERMINAL states instead
6952    // of no-op'ing: a module whose restart budget exhausted (Failed) or that exited
6953    // clean (Stopped) has no live process and no other in-band recovery — the
6954    // operator's start is the explicit recovery act and resets the budget. Without
6955    // this arm the only revival was subc-probe --supervisor-restart in a terminal,
6956    // which the 2026-07-14 aft outage proved is a trap when the failed module is
6957    // the one providing every agent's shell.
6958    let revive_terminal = enabled
6959        && current_enabled
6960        && child.is_none()
6961        && (matches!(current_state, ModuleState::Failed | ModuleState::Stopped)
6962            || (current_state == ModuleState::Restarting && !respawn_pending));
6963    if current_enabled == enabled && !revive_terminal {
6964        return Ok(false);
6965    }
6966
6967    if enabled {
6968        update_snapshot(snapshot, Some(&spec.module_id), |state| {
6969            state.enabled = true;
6970            state.state = ModuleState::Starting;
6971            clear_current_process_facts(state);
6972        })?;
6973        #[cfg(test)]
6974        if runtime.test_seed_stale_facts_before_enable_spawn {
6975            update_snapshot(snapshot, Some(&spec.module_id), |state| {
6976                state.process_alive = true;
6977                state.pid = Some(41);
6978                state.spawned_at_ms = Some(42);
6979                state.spawned_from = Some(PathBuf::from("/spawned/module"));
6980                state.spawned_file_identity = Some(SpawnedFileIdentity {
6981                    device: 43,
6982                    inode: 44,
6983                    #[cfg(windows)]
6984                    windows_image: None,
6985                });
6986            })?;
6987        }
6988        release_dead_registration(
6989            registry,
6990            runtime.forwarding.as_deref(),
6991            snapshot,
6992            &spec.module_id,
6993        )
6994        .await?;
6995        reset_restart_count(snapshot, &spec.module_id)?;
6996        process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
6997        let next_child = match spawn_and_mark_running(spec, runtime, snapshot) {
6998            Ok(next_child) => next_child,
6999            Err(err) => {
7000                if let Err(state_err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
7001                    state.state = ModuleState::Failed;
7002                    clear_current_process_facts(state);
7003                }) {
7004                    error!(module_id = %spec.module_id, error = %state_err, "failed to record enable spawn failure");
7005                }
7006                process_liveness.untrack_if_current(&spec.module_id, snapshot);
7007                return Err(err);
7008            }
7009        };
7010        *child = Some(next_child);
7011        debug!(module_id = %spec.module_id, "supervised module enabled");
7012        Ok(true)
7013    } else {
7014        let stop_notice = begin_forwarding_drain_if_configured(
7015            spec,
7016            runtime,
7017            registry,
7018            snapshot,
7019            Some(false),
7020            RouteCloseReason::Disable,
7021        )
7022        .await?;
7023        drain_optional_child(
7024            &spec.module_id,
7025            spec.protocol,
7026            stop_notice,
7027            registry,
7028            runtime.forwarding.as_deref(),
7029            snapshot,
7030            &runtime.terminal_ring,
7031            &runtime.spawn_events,
7032            child,
7033            runtime.drain_timeout,
7034            ModuleState::Disabled,
7035            Some(false),
7036        )
7037        .await?;
7038        debug!(module_id = %spec.module_id, "supervised module disabled");
7039        Ok(true)
7040    }
7041}
7042
7043#[allow(clippy::too_many_arguments)]
7044async fn on_child_exit(
7045    spec: &ModuleSpec,
7046    policy: RestartPolicy,
7047    registry: &Registry,
7048    snapshot: &SharedSnapshot,
7049    terminal_ring: &Arc<Mutex<TerminalRing>>,
7050    spawn_events: &SpawnEventFeed,
7051    roster: &ChildRoster,
7052    exit_report: ExitReport,
7053) -> NextAction {
7054    // Once the daemon has begun shutting down, no exit is a crash to recover
7055    // from: the module is exiting because the daemon is going away (EOF on its
7056    // connection, or a service manager signalling the whole cgroup). Record it
7057    // as such and never schedule a respawn, which would only start a process
7058    // for the shutdown to end again.
7059    if roster.is_closed() {
7060        return on_child_exit_during_daemon_shutdown(
7061            spec,
7062            registry,
7063            snapshot,
7064            terminal_ring,
7065            spawn_events,
7066            exit_report,
7067        )
7068        .await;
7069    }
7070    // Every stop the supervisor itself asks for (operator stop, disable,
7071    // restart, reload, swap, a health restart, a drain that runs out of budget)
7072    // takes the child out of the supervise loop and reaps it in
7073    // `drain_child_to_state`, and daemon shutdown is handled above. So an exit
7074    // that reaches this point was not requested by the daemon.
7075    //
7076    // For a subc-wire module a clean exit is still a stop: those modules are
7077    // written to re-raise SIGTERM, so a stray outside signal already reads as a
7078    // crash, and exiting 0 is a deliberate choice the module made. A
7079    // `protocol: "none"` module is a stock program we cannot change, and many
7080    // of them (nats-server among them) exit 0 on SIGTERM. Treating that as a
7081    // stop would leave the module down for good after any stray signal, so it
7082    // goes through the crash path instead: it spends restart budget, respawns
7083    // with the crash backoff, and ends `failed` when the budget runs out.
7084    let unrequested_clean_exit_of_protocol_none = exit_report.kind == ExitKind::Clean
7085        && running_protocol(spec, snapshot) == ModuleProtocol::None;
7086    match exit_report.kind {
7087        ExitKind::Clean if !unrequested_clean_exit_of_protocol_none => {
7088            info!(
7089                module_id = %spec.module_id,
7090                exit_code = ?exit_report.code,
7091                exit_signal = ?exit_report.signal,
7092                "supervised module exited cleanly"
7093            );
7094            if let Err(err) = publish_after_terminal_record(
7095                &spec.module_id,
7096                || {
7097                    record_terminal(
7098                        &spec.module_id,
7099                        terminal_ring,
7100                        spawn_events,
7101                        &exit_report,
7102                        TerminalDisposition::Stopped,
7103                    )
7104                },
7105                || {
7106                    update_snapshot(snapshot, Some(&spec.module_id), |state| {
7107                        state.state = ModuleState::Stopped;
7108                        clear_current_process_facts(state);
7109                        state.last_exit = Some(exit_report.clone());
7110                    })
7111                },
7112            ) {
7113                error!(module_id = %spec.module_id, error = %err, "failed to publish clean module exit");
7114            }
7115            let registration_released = match wait_for_registration_release(
7116                registry,
7117                &spec.module_id,
7118                REGISTRY_RELEASE_TIMEOUT,
7119            )
7120            .await
7121            {
7122                Ok(()) => true,
7123                Err(err) => {
7124                    warn!(module_id = %spec.module_id, error = %err, "registration still active after clean exit");
7125                    false
7126                }
7127            };
7128            NextAction::Stop {
7129                registration_released,
7130            }
7131        }
7132        ExitKind::Clean | ExitKind::Crash => {
7133            if unrequested_clean_exit_of_protocol_none {
7134                warn!(
7135                    module_id = %spec.module_id,
7136                    exit_code = ?exit_report.code,
7137                    exit_signal = ?exit_report.signal,
7138                    "protocol-none module exited cleanly without a stop request; handling it as a crash"
7139                );
7140            } else {
7141                warn!(
7142                    module_id = %spec.module_id,
7143                    exit_code = ?exit_report.code,
7144                    exit_signal = ?exit_report.signal,
7145                    "supervised module exited abnormally (crash)"
7146                );
7147            }
7148            let mut restart_schedule = None;
7149            let mut disposition = TerminalDisposition::Disabled;
7150            let mut final_state = ModuleState::Disabled;
7151            // Set only when the budget is what stopped the module, so the
7152            // terminal record says which limit was hit rather than leaving
7153            // `failed` to be read as "crashed once, badly".
7154            let mut disposition_detail = lock_snapshot(snapshot)
7155                .ok()
7156                .and_then(|mut state| state.spawn_failure.take());
7157            let now = Instant::now();
7158            if let Err(err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
7159                if state.enabled {
7160                    if let Some(schedule) = state.next_crash_restart(&policy, now) {
7161                        final_state = ModuleState::Restarting;
7162                        restart_schedule = Some(schedule);
7163                        disposition = TerminalDisposition::Restarting;
7164                    } else {
7165                        final_state = ModuleState::Failed;
7166                        disposition = TerminalDisposition::Failed;
7167                        let budget = policy.budget_exhausted_detail();
7168                        disposition_detail =
7169                            Some(disposition_detail.take().map_or_else(
7170                                || budget.clone(),
7171                                |cause| format!("{cause}; {budget}"),
7172                            ));
7173                    }
7174                } else {
7175                    disposition = TerminalDisposition::Disabled;
7176                }
7177            }) {
7178                error!(module_id = %spec.module_id, error = %err, "failed to record crashed module exit");
7179                return NextAction::Stop {
7180                    registration_released: false,
7181                };
7182            }
7183            if disposition == TerminalDisposition::Failed {
7184                // The window is in the message, not only in the fields: this line
7185                // is read in a scrollback where a bare `max_restarts=3` reads as a
7186                // lifetime cap and sends the operator looking for three crashes
7187                // that never happened together.
7188                error!(
7189                    module_id = %spec.module_id,
7190                    max_restarts = policy.max_restarts,
7191                    window_secs = policy.window.as_secs(),
7192                    "module stopped: {}",
7193                    policy.budget_exhausted_detail()
7194                );
7195            }
7196            if let Err(err) = publish_after_terminal_record(
7197                &spec.module_id,
7198                || {
7199                    record_terminal_with_detail(
7200                        &spec.module_id,
7201                        terminal_ring,
7202                        spawn_events,
7203                        &exit_report,
7204                        disposition,
7205                        disposition_detail,
7206                    )
7207                },
7208                || {
7209                    update_snapshot(snapshot, Some(&spec.module_id), |state| {
7210                        state.state = final_state;
7211                        clear_current_process_facts(state);
7212                        state.last_exit = Some(exit_report.clone());
7213                    })
7214                },
7215            ) {
7216                error!(module_id = %spec.module_id, error = %err, "failed to publish crashed module exit");
7217            }
7218
7219            if let Some(schedule) = restart_schedule {
7220                NextAction::Restart {
7221                    schedule: Some(schedule),
7222                }
7223            } else {
7224                let registration_released = match wait_for_registration_release(
7225                    registry,
7226                    &spec.module_id,
7227                    REGISTRY_RELEASE_TIMEOUT,
7228                )
7229                .await
7230                {
7231                    Ok(()) => true,
7232                    Err(err) => {
7233                        warn!(module_id = %spec.module_id, error = %err, "registration still active after failed module");
7234                        false
7235                    }
7236                };
7237                NextAction::Stop {
7238                    registration_released,
7239                }
7240            }
7241        }
7242        ExitKind::DeliberateSeverance => {
7243            warn!(
7244                module_id = %spec.module_id,
7245                exit_code = ?exit_report.code,
7246                exit_signal = ?exit_report.signal,
7247                "supervised module exited after deliberate connection severance"
7248            );
7249            let should_restart = match lock_snapshot(snapshot) {
7250                Ok(state) => state.enabled,
7251                Err(err) => {
7252                    error!(module_id = %spec.module_id, error = %err, "failed to read deliberately severed module state");
7253                    return NextAction::Stop {
7254                        registration_released: false,
7255                    };
7256                }
7257            };
7258            let final_state = if should_restart {
7259                ModuleState::Restarting
7260            } else {
7261                ModuleState::Disabled
7262            };
7263            if let Err(err) = publish_after_terminal_record(
7264                &spec.module_id,
7265                || {
7266                    record_terminal(
7267                        &spec.module_id,
7268                        terminal_ring,
7269                        spawn_events,
7270                        &exit_report,
7271                        terminal_disposition(final_state),
7272                    )
7273                },
7274                || {
7275                    update_snapshot(snapshot, Some(&spec.module_id), |state| {
7276                        state.state = final_state;
7277                        clear_current_process_facts(state);
7278                        state.last_exit = Some(exit_report.clone());
7279                        state.lifetime_restarts += 1;
7280                    })
7281                },
7282            ) {
7283                error!(module_id = %spec.module_id, error = %err, "failed to publish deliberately severed module exit");
7284                return NextAction::Stop {
7285                    registration_released: false,
7286                };
7287            }
7288
7289            if should_restart {
7290                NextAction::Restart { schedule: None }
7291            } else {
7292                let registration_released = match wait_for_registration_release(
7293                    registry,
7294                    &spec.module_id,
7295                    REGISTRY_RELEASE_TIMEOUT,
7296                )
7297                .await
7298                {
7299                    Ok(()) => true,
7300                    Err(err) => {
7301                        warn!(module_id = %spec.module_id, error = %err, "registration still active after deliberately severed module exit");
7302                        false
7303                    }
7304                };
7305                NextAction::Stop {
7306                    registration_released,
7307                }
7308            }
7309        }
7310    }
7311}
7312
7313async fn on_child_exit_during_daemon_shutdown(
7314    spec: &ModuleSpec,
7315    registry: &Registry,
7316    snapshot: &SharedSnapshot,
7317    terminal_ring: &Arc<Mutex<TerminalRing>>,
7318    spawn_events: &SpawnEventFeed,
7319    exit_report: ExitReport,
7320) -> NextAction {
7321    info!(
7322        module_id = %spec.module_id,
7323        exit_code = ?exit_report.code,
7324        exit_signal = ?exit_report.signal,
7325        exit_kind = ?exit_report.kind,
7326        "supervised module exited during daemon shutdown; not restarting it"
7327    );
7328    if let Err(err) = publish_after_terminal_record(
7329        &spec.module_id,
7330        || {
7331            record_terminal(
7332                &spec.module_id,
7333                terminal_ring,
7334                spawn_events,
7335                &exit_report,
7336                TerminalDisposition::DaemonShutdown,
7337            )
7338        },
7339        || {
7340            update_snapshot(snapshot, Some(&spec.module_id), |state| {
7341                state.state = ModuleState::Stopped;
7342                clear_current_process_facts(state);
7343                state.last_exit = Some(exit_report.clone());
7344            })
7345        },
7346    ) {
7347        error!(module_id = %spec.module_id, error = %err, "failed to publish module exit during daemon shutdown");
7348    }
7349    let registration_released =
7350        wait_for_registration_release(registry, &spec.module_id, REGISTRY_RELEASE_TIMEOUT)
7351            .await
7352            .is_ok();
7353    NextAction::Stop {
7354        registration_released,
7355    }
7356}
7357
7358// Keep the status lock out of terminal I/O. Readers may observe the previous
7359// state while recording is in progress, but never a completed exit without its
7360// history. Journal errors are logged by the journal and retained in the ring;
7361// a recording panic still publishes the final state before resuming the unwind.
7362fn publish_after_terminal_record<T>(
7363    module_id: &str,
7364    record: impl FnOnce(),
7365    publish: impl FnOnce() -> T,
7366) -> T {
7367    let recorded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(record));
7368    let published = publish();
7369    if let Err(panic) = recorded {
7370        error!(
7371            module_id,
7372            "terminal recording panicked; published final state before resuming unwind"
7373        );
7374        std::panic::resume_unwind(panic);
7375    }
7376    published
7377}
7378
7379fn fail_after_wait_error(
7380    module_id: &str,
7381    snapshot: &SharedSnapshot,
7382    terminal_ring: &Arc<Mutex<TerminalRing>>,
7383    spawn_events: &SpawnEventFeed,
7384    detail: String,
7385) {
7386    let report = wait_error_exit_report();
7387    publish_after_terminal_record(
7388        module_id,
7389        || {
7390            record_terminal_with_detail(
7391                module_id,
7392                terminal_ring,
7393                spawn_events,
7394                &report,
7395                TerminalDisposition::Failed,
7396                Some(detail),
7397            )
7398        },
7399        || fail_snapshot(snapshot, Some(module_id), Some(report.clone())),
7400    );
7401}
7402
7403#[cfg(test)]
7404fn record_wait_error_terminal(
7405    module_id: &str,
7406    terminal_ring: &Arc<Mutex<TerminalRing>>,
7407    spawn_events: &SpawnEventFeed,
7408) {
7409    record_terminal(
7410        module_id,
7411        terminal_ring,
7412        spawn_events,
7413        &wait_error_exit_report(),
7414        TerminalDisposition::Failed,
7415    );
7416}
7417
7418fn record_terminal(
7419    module_id: &str,
7420    terminal_ring: &Arc<Mutex<TerminalRing>>,
7421    spawn_events: &SpawnEventFeed,
7422    exit_report: &ExitReport,
7423    disposition: TerminalDisposition,
7424) {
7425    record_terminal_with_detail(
7426        module_id,
7427        terminal_ring,
7428        spawn_events,
7429        exit_report,
7430        disposition,
7431        None,
7432    );
7433}
7434
7435/// The ring lock is held only to capture the read (see
7436/// `TerminalJournal::capture_read`), so this module's exits keep recording
7437/// while the journal files are read. Blocking: it reads files.
7438fn durable_terminal_history_of(
7439    terminal_ring: &Mutex<TerminalRing>,
7440    module_id: &str,
7441) -> subc_control::TerminalHistory {
7442    let read = terminal_ring
7443        .lock()
7444        .unwrap_or_else(|p| p.into_inner())
7445        .capture_durable_history();
7446    read.read(module_id)
7447}
7448
7449fn record_terminal_with_detail(
7450    module_id: &str,
7451    terminal_ring: &Arc<Mutex<TerminalRing>>,
7452    spawn_events: &SpawnEventFeed,
7453    exit_report: &ExitReport,
7454    disposition: TerminalDisposition,
7455    disposition_detail: Option<String>,
7456) {
7457    #[cfg(test)]
7458    terminal_history_tests::before_terminal_record(spawn_events);
7459    spawn_events.emit_exited(module_id, exit_report.code, exit_report.signal);
7460    let record = TerminalRecord {
7461        exit_code: exit_report.code,
7462        exit_signal: exit_report.signal,
7463        at_ms: exit_report.at_ms,
7464        disposition,
7465        exit_kind: exit_report.kind.into(),
7466        disposition_detail,
7467    };
7468    terminal_ring
7469        .lock()
7470        .unwrap_or_else(|poisoned| poisoned.into_inner())
7471        .record_exit(module_id, record);
7472}
7473
7474fn untrack_if_registration_released(
7475    process_liveness: &SupervisorProcessLiveness,
7476    registry: &Registry,
7477    module_id: &str,
7478    snapshot: &SharedSnapshot,
7479) {
7480    match registry.get_module(module_id) {
7481        Ok(None) => process_liveness.untrack_if_current(module_id, snapshot),
7482        Ok(Some(_)) => {}
7483        Err(err) => {
7484            warn!(module_id, error = %err, "could not determine whether supervisor liveness can be untracked");
7485        }
7486    }
7487}
7488
7489/// The child's environment plan: inherit the parent's, drop ambient `CK_LOG`,
7490/// then apply the module's configured entries minus daemon-private capture keys.
7491///
7492/// Separated from `spawn_child` only so it can be asserted without spawning a
7493/// process — a duplicate of this logic in a test would pass while the real one
7494/// drifted, which is the defect class this function exists to avoid.
7495/// The subc-wire half of a spawn: `--subc <connection file>` and the launch
7496/// nonce. A `protocol: "none"` module gets neither, because it cannot use
7497/// either and the argument would stop a stock binary from starting at all.
7498/// `SUBC_MODULE_ID` is set on every path since an unread variable is inert.
7499///
7500/// The plain-spawn form, kept for the tests that assert its plan; spawns go
7501/// through [`apply_wire_spawn_args_for_role`].
7502#[cfg(test)]
7503fn apply_wire_spawn_args(
7504    command: &mut Command,
7505    spec: &ModuleSpec,
7506    connection_file_path: Option<&std::path::Path>,
7507    handle: Option<&SupervisorHandle>,
7508) -> Result<Option<NonceHandoff>, SuperviseError> {
7509    apply_wire_spawn_args_for_role(
7510        command,
7511        spec,
7512        connection_file_path,
7513        handle,
7514        SpawnRole::Plain,
7515    )
7516}
7517
7518/// The read end of a spawn's launch-nonce pipe, prepared by
7519/// [`apply_wire_spawn_args_for_role`] and installed as the child's descriptor 3
7520/// by the last pre-exec step, just before `spawn()`. Windows prepares a named
7521/// server before spawn and authenticates its client after the child exists.
7522#[cfg(unix)]
7523type NonceHandoff = subc_os::LaunchNonceHandoff;
7524#[cfg(windows)]
7525type NonceHandoff = subc_os::LaunchNoncePipeHandoff;
7526#[cfg(not(any(unix, windows)))]
7527type NonceHandoff = std::convert::Infallible;
7528
7529/// Create the launch nonce that a new module process must present when it
7530/// registers or opens a consumer connection, including a replacement process
7531/// started alongside a still-serving module.
7532///
7533/// A plain spawn replaces the module's recorded nonce. A swap candidate records
7534/// a separate candidate token so the still-serving incumbent and its consumers
7535/// keep their nonce. Both records are installed before the process exists, so
7536/// the child's initial HELLO registration cannot arrive ahead of its nonce.
7537///
7538/// On Unix the nonce is delivered only through a pipe. It is written into
7539/// a pipe whose read end the child gets as descriptor 3, named by
7540/// `SUBC_LAUNCH_NONCE_FD=3:<pipe inode>`: unlike the environment, another
7541/// process of the same user cannot read it with `ps eww`. That handoff is
7542/// returned rather than installed here, because installing it replaces
7543/// whatever the child has at descriptor 3 and so must be the last pre-exec
7544/// step. Later in `spawn_child_in_slot`, `apply_cgroup_placement` first registers
7545/// the Linux callback that writes the process into its cgroup. Only after that
7546/// does `handoff.install_last` register the descriptor-3 replacement, so it
7547/// cannot close a descriptor the cgroup callback still needs.
7548/// Windows keeps SUBC_LAUNCH_NONCE as well until every module reads the named pipe.
7549fn apply_wire_spawn_args_for_role(
7550    command: &mut Command,
7551    spec: &ModuleSpec,
7552    connection_file_path: Option<&std::path::Path>,
7553    handle: Option<&SupervisorHandle>,
7554    role: SpawnRole,
7555) -> Result<Option<NonceHandoff>, SuperviseError> {
7556    command.env(SUBC_MODULE_ID_ENV, &spec.module_id);
7557    // SUBC_LAUNCH_NONCE_FD is removed for every child, `protocol: "none"`
7558    // included: a daemon started from a module's process tree inherits it,
7559    // and passing it on would point the child at a descriptor it does not
7560    // have.
7561    command.env_remove(subc_os::LAUNCH_NONCE_FD_ENV);
7562    #[cfg(windows)]
7563    {
7564        command.env_remove(subc_os::LAUNCH_NONCE_PIPE_ENV);
7565        command.env_remove(subc_os::LAUNCH_NONCE_PIPE_FALLBACK_ENV);
7566    }
7567    // Remove inherited or configured copies too: withholding must mean absent.
7568    command.env_remove(SUBC_LAUNCH_NONCE_ENV);
7569    if spec.protocol == ModuleProtocol::None {
7570        return Ok(None);
7571    }
7572    if let Some(connection_file_path) = connection_file_path {
7573        command.arg(SUBC_ARG).arg(connection_file_path);
7574    }
7575
7576    // Every subc-wire spawn receives a fresh one-time launch nonce for consumer
7577    // route.open attestation. Reserved modules additionally use the same nonce
7578    // for HELLO id-squatting protection. A respawn rotates both records.
7579    let nonce = generate_launch_nonce()?;
7580    if let Some(handle) = handle {
7581        match role {
7582            SpawnRole::Plain => {
7583                handle.set_spawn_nonce(&spec.module_id, nonce.clone());
7584                if spec.reserved {
7585                    handle.set_reserved_nonce(&spec.module_id, nonce.clone());
7586                }
7587            }
7588            SpawnRole::SwapCandidate => handle.open_swap(&spec.module_id, nonce.clone()),
7589        }
7590    }
7591    #[cfg(unix)]
7592    let handoff = {
7593        let handoff =
7594            subc_os::LaunchNonceHandoff::new(&nonce).map_err(|source| SuperviseError::Spawn {
7595                program: spec.program.clone(),
7596                source,
7597                cgroup_path: None,
7598            })?;
7599        command.env(subc_os::LAUNCH_NONCE_FD_ENV, handoff.fd_env_value());
7600        Some(handoff)
7601    };
7602    #[cfg(windows)]
7603    let handoff = prepare_windows_nonce_pipe(command, spec, &nonce);
7604    #[cfg(not(any(unix, windows)))]
7605    let handoff = None;
7606    // Older modules still read SUBC_LAUNCH_NONCE to register. Keep that copy
7607    // until the daemon's per-spawn delivery records show that every running
7608    // Windows module has read its secret from the named pipe.
7609    #[cfg(not(unix))]
7610    command.env(SUBC_LAUNCH_NONCE_ENV, nonce);
7611    Ok(handoff)
7612}
7613
7614fn apply_child_env(command: &mut Command, spec: &ModuleSpec) {
7615    command.env_remove(CK_LOG_ENV);
7616    // The spawn role is the supervisor's to set, and only on a swap candidate
7617    // (see `apply_spawn_role`). Removing it here, rather than just not setting
7618    // it, is what makes it absent on a plain spawn: the daemon's own
7619    // environment could carry it, and so could a spec built outside daemon
7620    // config (config refuses it as an `env` key). A module reading it on a
7621    // plain restart would pick the long swap budget and leave callers waiting.
7622    command.env_remove(SUBC_SPAWN_ROLE_ENV);
7623    for (key, value) in &spec.env {
7624        // cortexkit-log currently exposes retention only as a Rust struct, not
7625        // environment names. These values are daemon-private sink metadata and
7626        // must never become a public child-process contract by being inherited.
7627        if matches!(
7628            key.as_str(),
7629            CAPTURE_MAX_FILE_MB_ENV | CAPTURE_KEEP_ENV | CAPTURE_MAX_AGE_DAYS_ENV
7630        ) || key == SUBC_SPAWN_ROLE_ENV
7631        {
7632            continue;
7633        }
7634        command.env(key, value);
7635    }
7636}
7637
7638#[cfg(windows)]
7639fn prepare_windows_nonce_pipe(
7640    command: &mut Command,
7641    spec: &ModuleSpec,
7642    nonce: &str,
7643) -> Option<NonceHandoff> {
7644    match subc_os::LaunchNoncePipeHandoff::new(nonce) {
7645        Ok(handoff) => {
7646            command.env(subc_os::LAUNCH_NONCE_PIPE_ENV, handoff.name());
7647            // Let a failed pipe read use SUBC_LAUNCH_NONCE so the process can
7648            // still register until every module reads the pipe. A reader without
7649            // this variable set to env returns the pipe error instead of using
7650            // an inherited or configured environment copy.
7651            command.env(subc_os::LAUNCH_NONCE_PIPE_FALLBACK_ENV, "env");
7652            Some(handoff)
7653        }
7654        Err(error) => {
7655            warn!(module_id = %spec.module_id, %error, "could not prepare launch nonce pipe; retaining the environment handoff");
7656            None
7657        }
7658    }
7659}
7660
7661#[cfg(windows)]
7662fn start_windows_nonce_pipe(
7663    handoff: Option<NonceHandoff>,
7664    pid: u32,
7665    spec: &ModuleSpec,
7666) -> Option<subc_os::LaunchNoncePipeDelivery> {
7667    // Close an unread pipe after REGISTRY_RELEASE_TIMEOUT (one second), the
7668    // same maximum wait used for registration during a reload. Older modules
7669    // may register using SUBC_LAUNCH_NONCE without ever connecting to the pipe.
7670    handoff.and_then(|handoff| match handoff.serve(pid, REGISTRY_RELEASE_TIMEOUT) {
7671        Ok(delivery) => Some(delivery),
7672        Err(error) => {
7673            warn!(module_id = %spec.module_id, %error, "could not serve launch nonce pipe; retaining the environment handoff");
7674            None
7675        }
7676    })
7677}
7678
7679fn observed_launch_nonce_source(snapshot: &SupervisorSnapshot) -> Option<String> {
7680    #[cfg(windows)]
7681    if snapshot.process_alive && snapshot.spawned_protocol == Some(ModuleProtocol::Subc) {
7682        return Some(
7683            if snapshot
7684                .pipe_consumed
7685                .as_ref()
7686                .is_some_and(|consumed| consumed.get().is_some())
7687            {
7688                "pipe"
7689            } else {
7690                "env"
7691            }
7692            .to_owned(),
7693        );
7694    }
7695    let _ = snapshot;
7696    None
7697}
7698
7699/// Which slot a spawn fills: the module's ordinary one, or the candidate slot
7700/// of a blue/green swap.
7701#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7702enum SpawnRole {
7703    Plain,
7704    SwapCandidate,
7705}
7706
7707/// Set the spawn role for a swap candidate. A plain spawn gets nothing here;
7708/// `apply_child_env` has already removed the variable for every spawn.
7709fn apply_spawn_role(command: &mut Command, role: SpawnRole) {
7710    if role == SpawnRole::SwapCandidate {
7711        command.env(SUBC_SPAWN_ROLE_ENV, SPAWN_ROLE_SWAP_CANDIDATE);
7712    }
7713}
7714
7715fn spawn_child(
7716    spec: &ModuleSpec,
7717    connection_file_path: Option<&std::path::Path>,
7718    handle: Option<&SupervisorHandle>,
7719    ring: &Arc<Mutex<StderrRing>>,
7720    capture_logs_dir: Option<&std::path::Path>,
7721    roster: &ChildRoster,
7722    #[cfg(target_os = "linux")] cgroup_placement: Option<&subc_cgroup::Placement>,
7723) -> Result<SupervisedChild, SuperviseError> {
7724    spawn_child_in_slot(
7725        spec,
7726        connection_file_path,
7727        handle,
7728        ring,
7729        capture_logs_dir,
7730        roster,
7731        #[cfg(target_os = "linux")]
7732        cgroup_placement,
7733        SpawnRole::Plain,
7734        false,
7735    )
7736}
7737
7738/// Spawn one process of `spec` into a slot.
7739///
7740/// `alternate_slot` picks the process's cgroup label on Linux (see `swap::cgroup_name`).
7741/// A swap candidate needs a different cgroup from the process it is replacing,
7742/// which is still alive: in the same cgroup the two would be one kill domain,
7743/// and killing a failed candidate could take the incumbent with it.
7744///
7745/// The stderr capture file is `<module_id>.stderr.log` for every process of
7746/// the module, whichever slot it is in, because that is the one file
7747/// `ck module logs` reads. During a swap's overlap both processes append to it;
7748/// the daemon writes whole lines, so the two interleave by line, which is also
7749/// the merged view an operator wants while a swap runs.
7750#[allow(clippy::too_many_arguments)]
7751fn spawn_child_in_slot(
7752    spec: &ModuleSpec,
7753    connection_file_path: Option<&std::path::Path>,
7754    handle: Option<&SupervisorHandle>,
7755    ring: &Arc<Mutex<StderrRing>>,
7756    capture_logs_dir: Option<&std::path::Path>,
7757    roster: &ChildRoster,
7758    #[cfg(target_os = "linux")] cgroup_placement: Option<&subc_cgroup::Placement>,
7759    role: SpawnRole,
7760    alternate_slot: bool,
7761) -> Result<SupervisedChild, SuperviseError> {
7762    if roster.is_closed() {
7763        return Err(SuperviseError::Spawn {
7764            program: spec.program.clone(),
7765            source: io::Error::other("the daemon is shutting down; not starting a new process"),
7766            cgroup_path: None,
7767        });
7768    }
7769    #[cfg(target_os = "linux")]
7770    let cgroup_name = {
7771        // Slot names alone are not kill domains: a retired incumbent may still
7772        // be draining when a later enable/restart spawns into the same slot.
7773        // Decimal entropy keeps the suffix unambiguous; Placement performs
7774        // the module-id escaping and constructs the filesystem path.
7775        if cgroup_placement.is_none() {
7776            swap::cgroup_name(&spec.module_id, alternate_slot)
7777        } else {
7778            let nonce = generate_launch_nonce()?;
7779            let suffix = u128::from_str_radix(&nonce[..32], 16).expect("hex launch nonce");
7780            // Leave room for byte escaping and the suffix under NAME_MAX. The
7781            // label is only for humans; the nonce identifies the kill domain.
7782            let mut end = spec.module_id.len().min(64);
7783            while !spec.module_id.is_char_boundary(end) {
7784                end -= 1;
7785            }
7786            format!(
7787                "{}_{suffix}",
7788                swap::cgroup_name(&spec.module_id[..end], alternate_slot)
7789            )
7790        }
7791    };
7792    #[cfg(not(target_os = "linux"))]
7793    let _ = alternate_slot;
7794    #[cfg(target_os = "macos")]
7795    let (mut command, privacy_exec, exec_ack) = privacy_command(spec, roster)?;
7796    #[cfg(windows)]
7797    let image_capture = windows_spawn_capture(spec).map_err(|source| SuperviseError::Spawn {
7798        program: spec.program.clone(),
7799        source,
7800        cgroup_path: None,
7801    })?;
7802    #[cfg(windows)]
7803    let mut command = Command::new(image_capture.path());
7804    #[cfg(not(any(target_os = "macos", windows)))]
7805    let mut command = Command::new(&spec.program);
7806    command.args(&spec.args);
7807    // AMBIENT `CK_LOG` MUST NOT LEAK INTO AN OTHERWISE UNCONFIGURED MODULE — but
7808    // that is the whole of the intent, so remove that one key rather than the
7809    // environment.
7810    //
7811    // This was `env_clear()` from 0.17.41 until 0.18.3, which achieved the goal
7812    // and took the POSIX environment with it. Modules spawned that way had no
7813    // HOME, XDG_RUNTIME_DIR, TMPDIR or USER, and the consequences ran past
7814    // logging:
7815    //
7816    //   * `connection_file::discover` reads XDG_RUNTIME_DIR and HOME, so with
7817    //     both unset it fell back to the temp dir alone and `ck` could not find
7818    //     a daemon running on the same machine from inside any module's process
7819    //     tree — reporting a path the file has never lived at, which reads as
7820    //     "the daemon did not write its file".
7821    //   * `default_data_home()` with HOME and XDG_DATA_HOME both unset returns
7822    //     the RELATIVE `.local/share`, so a module deriving its own store path
7823    //     resolved it against its own CWD. That is the store-fragmentation
7824    //     defect the daemon already refuses in config (`parse_doc` rejects a
7825    //     relative `storage.data_home`) arriving by derivation instead.
7826    //   * anything a module spawns inherited it: git without ~/.gitconfig,
7827    //     cargo without CARGO_HOME, ssh, python user dirs — all degrading
7828    //     quietly rather than erroring.
7829    //
7830    // Reported by iceteaSA as #104 after deploying 0.18.2, where `ck daemon`
7831    // offered one candidate under /tmp while the file sat in /run/user/1000.
7832    //
7833    // A configured module is unaffected either way: `module_spec()` puts the
7834    // resolved CK_LOG into `spec.env`, which is applied below and therefore
7835    // wins over anything ambient.
7836    apply_child_env(&mut command, spec);
7837    apply_spawn_role(&mut command, role);
7838    let nonce_handoff =
7839        apply_wire_spawn_args_for_role(&mut command, spec, connection_file_path, handle, role)?;
7840
7841    #[cfg(target_os = "linux")]
7842    let cgroup_path = cgroup_placement
7843        .map(|placement| placement.module_path(&cgroup_name))
7844        .transpose()
7845        .map_err(|source| SuperviseError::Cgroup {
7846            module_id: spec.module_id.clone(),
7847            source,
7848        })?;
7849    #[cfg(not(target_os = "linux"))]
7850    let cgroup_path: Option<PathBuf> = None;
7851    #[cfg(target_os = "linux")]
7852    if let Some(path) = &cgroup_path {
7853        if let Err(error) = apply_cgroup_placement(&mut command, spec, path) {
7854            if let Some(placement) = cgroup_placement {
7855                remove_module_cgroup(placement, &cgroup_name);
7856            }
7857            return Err(error);
7858        }
7859    }
7860
7861    let output_sink = if let Some(logs_dir) = capture_logs_dir {
7862        let path = logs_dir.join(format!("{}.stderr.log", spec.module_id));
7863        match ChildOutputSink::open(&path, capture_retention(spec)) {
7864            Ok(sink) => sink,
7865            Err(error) => {
7866                warn!(
7867                    module_id = %spec.module_id,
7868                    path = %path.display(),
7869                    error = %error,
7870                    "could not open child output capture file; forwarding to stderr"
7871                );
7872                ChildOutputSink::Stderr
7873            }
7874        }
7875    } else {
7876        ChildOutputSink::Stderr
7877    };
7878
7879    command.stdout(Stdio::piped());
7880    command.stderr(Stdio::piped());
7881    command.kill_on_drop(true);
7882    // EACH MODULE LEADS ITS OWN PROCESS GROUP (the child calls setpgid(0, 0)
7883    // before exec). In the daemon's group, a service manager that kills the
7884    // job's process group when the daemon exits (launchd's default) killed
7885    // every module at the same moment its control connection closed, so no
7886    // module ever ran its EOF teardown on a daemon stop. Outside that group a
7887    // module is reached only by the daemon: the EOF it sees when its
7888    // connection closes, and the bounded stop in `child_roster` for anything
7889    // still running after that. On Linux this composes with the cgroup
7890    // placement above: that is a pre_exec write to cgroup.procs, std performs
7891    // setpgid in the child before running pre_exec callbacks, and the two
7892    // change independent process attributes.
7893    //
7894    // stdin is /dev/null because a process outside the terminal's foreground
7895    // group is stopped (SIGTTIN) if it reads the terminal, which a daemon run
7896    // by hand would otherwise hand down. Under a service manager stdin is
7897    // already /dev/null.
7898    #[cfg(unix)]
7899    command.process_group(0);
7900    command.stdin(Stdio::null());
7901    // The LAST pre-exec step, after the cgroup placement above: installing the
7902    // nonce at descriptor 3 replaces whatever the child had there, which could
7903    // be the descriptor an earlier step writes through.
7904    #[cfg(unix)]
7905    if let Some(handoff) = nonce_handoff {
7906        handoff.install_last(command.as_std_mut());
7907    }
7908    #[cfg(not(any(unix, windows)))]
7909    let _ = nonce_handoff;
7910
7911    // Containment, step 1 of 3 (issue #109): create the child suspended so it
7912    // cannot run a single instruction -- and therefore cannot spawn a
7913    // grandchild -- before it is in the job. See `contain_spawned_child` for the
7914    // other two steps and why the window matters.
7915    #[cfg(windows)]
7916    subc_jobobject::suspend_on_create_async(&mut command);
7917    let mut child = match command.spawn() {
7918        Ok(child) => child,
7919        Err(source) => {
7920            #[cfg(target_os = "linux")]
7921            if let Some(placement) = cgroup_placement {
7922                remove_module_cgroup(placement, &cgroup_name);
7923            }
7924            return Err(SuperviseError::Spawn {
7925                program: spec.program.clone(),
7926                source,
7927                cgroup_path,
7928            });
7929        }
7930    };
7931    // The parent must close its writer now: the acknowledgement pipe reports EOF
7932    // only when every writer is gone, and the child's copy closes when the
7933    // trampoline replaces itself with the module. Command holds only an integer
7934    // in its pre_exec callback, not another writer.
7935    #[cfg(target_os = "macos")]
7936    drop(exec_ack);
7937
7938    // Bind while the child is still suspended, before job assignment resumes it.
7939    // Keeping the executable handle open across CreateProcessW denies rename
7940    // and deletion, so the file cannot be exchanged during spawn. Bind that
7941    // file ID to the process handle returned by spawn, never a reopened PID.
7942    #[cfg(windows)]
7943    let windows_image =
7944        Arc::new(
7945            image_capture
7946                .bind_tokio(&child)
7947                .map_err(|source| SuperviseError::Spawn {
7948                    program: spec.program.clone(),
7949                    source,
7950                    cgroup_path: None,
7951                })?,
7952        );
7953    // Containment, steps 2 and 3: assign while suspended, then resume.
7954    #[cfg(windows)]
7955    let job = contain_spawned_child(&child, spec)?;
7956    let spawned_at_ms = unix_ms_now();
7957    #[cfg(not(windows))]
7958    let spawned_from = spec.program.clone();
7959    #[cfg(windows)]
7960    let spawned_from = windows_image.path().to_path_buf();
7961    #[cfg(not(windows))]
7962    let spawned_file_identity = spawned_file_identity(&spawned_from);
7963    #[cfg(windows)]
7964    let spawned_file_identity = Some(SpawnedFileIdentity {
7965        device: 0,
7966        inode: 0,
7967        windows_image: Some(Arc::clone(&windows_image)),
7968    });
7969    let pid = child.id().ok_or_else(|| SuperviseError::Spawn {
7970        program: spec.program.clone(),
7971        source: io::Error::other("spawned child exposed no live pid"),
7972        cgroup_path: cgroup_path.clone(),
7973    })?;
7974    #[cfg(windows)]
7975    let nonce_delivery = start_windows_nonce_pipe(nonce_handoff, pid, spec);
7976    #[cfg(not(windows))]
7977    let process_start_time = crate::provenance::process_start_time(pid);
7978    #[cfg(windows)]
7979    let process_start_time = Some(windows_image.start_time());
7980    let process_identity = process_start_time.map(|start_time| ProcessIdentity { pid, start_time });
7981    #[cfg(all(test, target_os = "macos"))]
7982    privacy_exec_boundary_tests::before_image_sample(spec, pid);
7983    // Unix spawn returns after exec's error pipe closes. The kernel image is
7984    // therefore the executable to compare during a future orphan sweep: PATH
7985    // lookup and shebang interpretation may select a different file from the
7986    // configured program. Keep the literal program's identity for provenance,
7987    // but never use it as proof that a recorded pid may be signalled.
7988    #[cfg(not(windows))]
7989    let recorded_image = observe_spawned_image(pid);
7990    // spawn() confirms only the first exec, into the trampoline. Never persist
7991    // the trampoline image; the asynchronous acknowledgement publishes the
7992    // module image once the trampoline has replaced itself with the module.
7993    #[cfg(target_os = "macos")]
7994    let recorded_image = if privacy_exec.is_some() {
7995        None
7996    } else {
7997        recorded_image
7998    };
7999    #[cfg(target_os = "linux")]
8000    let recorded_cgroup_name = cgroup_path.as_ref().map(|_| cgroup_name.clone());
8001    #[cfg(not(target_os = "linux"))]
8002    let recorded_cgroup_name = None;
8003    let roster_guard = roster.admit(
8004        spec.module_id.clone(),
8005        pid,
8006        spec.protocol,
8007        process_start_time,
8008        crate::child_roster::RecordedIdentity {
8009            #[cfg(not(windows))]
8010            start_time: recorded_image.map(|image| image.start_time),
8011            #[cfg(windows)]
8012            start_time: process_start_time,
8013            #[cfg(not(windows))]
8014            executable: recorded_image
8015                .and_then(|image| image.executable)
8016                .map(crate::live_children::ExecutableIdentity::from),
8017            #[cfg(windows)]
8018            executable: Some(crate::live_children::ExecutableIdentity::from(
8019                windows_image.identity(),
8020            )),
8021            cgroup_name: recorded_cgroup_name,
8022            #[cfg(target_os = "linux")]
8023            cgroup_placement: cgroup_placement.cloned(),
8024        },
8025    );
8026    // The check at the top of this function can pass just before daemon
8027    // shutdown begins, and the process is only in the roster from here on.
8028    // The shutdown stop returns as soon as it finds the roster empty, so a
8029    // process admitted after that look would outlive the daemon. The roster
8030    // is closed before the stop first reads it and admission happens under
8031    // the roster's lock, so either the stop sees this process or this check
8032    // sees the roster closed: end the process now rather than start a module
8033    // the daemon is about to stop.
8034    if roster.is_closed() {
8035        // This child was never admitted, so there is no module protocol shutdown to wait for.
8036        #[cfg(target_os = "linux")]
8037        kill_module_cgroup(cgroup_placement, &cgroup_name);
8038        if let Err(error) = child.start_kill() {
8039            debug!(module_id = %spec.module_id, pid, %error, "kill of a process spawned during daemon shutdown failed; it may already have exited");
8040        }
8041        #[cfg(target_os = "linux")]
8042        if let Some(placement) = cgroup_placement {
8043            // This spawn was never admitted, so shutdown has no roster entry
8044            // to await. Do not detach its cleanup: the runtime could exit
8045            // before that task reaps the rejected child and removes its group.
8046            while matches!(child.try_wait(), Ok(None)) {
8047                std::thread::yield_now();
8048            }
8049            if matches!(
8050                subc_cgroup::kill_module(Some(placement), &cgroup_name),
8051                subc_cgroup::KillOutcome::Killed
8052            ) {
8053                if let Ok(path) = placement.module_path(&cgroup_name) {
8054                    while std::fs::read_to_string(path.join("cgroup.events"))
8055                        .ok()
8056                        .is_some_and(|events| events.lines().any(|line| line == "populated 1"))
8057                    {
8058                        std::thread::yield_now();
8059                    }
8060                }
8061            }
8062            remove_module_cgroup(placement, &cgroup_name);
8063        }
8064        drop(roster_guard);
8065        return Err(SuperviseError::Spawn {
8066            program: spec.program.clone(),
8067            source: io::Error::other(
8068                "the daemon began shutting down while this process was starting; ended it",
8069            ),
8070            cgroup_path,
8071        });
8072    }
8073
8074    let stdout_pump = match child.stdout.take() {
8075        Some(stdout) => Some(tokio::spawn(pump_stdout_to(stdout, output_sink.clone()))),
8076        None => {
8077            warn!(
8078                module_id = %spec.module_id,
8079                "spawned child exposed no stdout pipe; file capture will be incomplete"
8080            );
8081            None
8082        }
8083    };
8084    let stderr_pump = match child.stderr.take() {
8085        Some(stderr) => {
8086            let generation = ring
8087                .lock()
8088                .unwrap_or_else(|poisoned| poisoned.into_inner())
8089                .begin_process();
8090            Some(StderrPump {
8091                task: tokio::spawn(pump_stderr_to(
8092                    stderr,
8093                    Arc::clone(ring),
8094                    generation,
8095                    output_sink,
8096                )),
8097                generation,
8098            })
8099        }
8100        None => {
8101            // Spawning succeeded but the pipe did not materialise. Recording it as
8102            // uncaptured keeps the tail honest: the alternative is an empty tail
8103            // that reads as a module which printed nothing.
8104            ring.lock()
8105                .unwrap_or_else(|poisoned| poisoned.into_inner())
8106                .mark_not_captured("stderr pipe was not available on spawn");
8107            warn!(
8108                module_id = %spec.module_id,
8109                "spawned child exposed no stderr pipe; tail will be unavailable"
8110            );
8111            None
8112        }
8113    };
8114
8115    Ok(SupervisedChild {
8116        child,
8117        #[cfg(windows)]
8118        nonce_delivery,
8119        protocol: spec.protocol,
8120        #[cfg(target_os = "linux")]
8121        module_id: cgroup_name,
8122        #[cfg(target_os = "linux")]
8123        cgroup_placement: cgroup_placement.cloned(),
8124        #[cfg(windows)]
8125        job,
8126        stdout_pump,
8127        stderr_pump,
8128        stderr_ring: Arc::clone(ring),
8129        spawned_at_ms,
8130        spawned_from,
8131        spawned_file_identity,
8132        process_start_time,
8133        process_identity,
8134        pid,
8135        roster_guard: Some(roster_guard),
8136        #[cfg(target_os = "macos")]
8137        privacy_exec,
8138        #[cfg(target_os = "macos")]
8139        report_ready: Arc::new(OnceLock::new()),
8140        spawn_failure: None,
8141    })
8142}
8143
8144#[cfg(target_os = "linux")]
8145pub(crate) fn kill_module_cgroup(placement: Option<&subc_cgroup::Placement>, module_id: &str) {
8146    use subc_cgroup::KillOutcome;
8147    match subc_cgroup::kill_module(placement, module_id) {
8148        KillOutcome::Killed => {}
8149        KillOutcome::NotPlaced | KillOutcome::Unsupported => {
8150            debug!(
8151                module_id,
8152                "cgroup tree kill unavailable; using direct-child kill"
8153            );
8154        }
8155        KillOutcome::IoError { path, error } => {
8156            warn!(module_id, path = %path.display(), %error, "cgroup tree kill failed; using direct-child kill");
8157        }
8158    }
8159}
8160
8161/// Contain a freshly spawned Windows child and start it.
8162///
8163/// Steps 2 and 3 of the suspended-create contract: the job is created and the
8164/// child assigned **while it is still suspended** (step 1 is
8165/// `suspend_on_create_async` at the spawn site), then the child is resumed.
8166///
8167/// A child that is never resumed hangs forever holding a pid, so a resume
8168/// failure kills the child and fails the spawn rather than returning a
8169/// `SupervisedChild` that can never run.
8170///
8171/// An assignment failure is NOT fatal: an uncontained module behaves exactly as
8172/// it did before this existed, whereas refusing to start one would be a new
8173/// outage. It is logged at warn because it means a helper process could leak.
8174#[cfg(windows)]
8175fn contain_spawned_child(
8176    child: &Child,
8177    spec: &ModuleSpec,
8178) -> Result<Option<subc_jobobject::JobObject>, SuperviseError> {
8179    let module_id = spec.module_id.as_str();
8180    let Some(pid) = child.id() else {
8181        // The child exited between spawn and here. Its tree, if it made one,
8182        // needs no containment: nothing is left to contain.
8183        warn!(
8184            module_id,
8185            "spawned child had already exited before containment; no job object attached"
8186        );
8187        return Ok(None);
8188    };
8189
8190    let job = match subc_jobobject::JobObject::new() {
8191        Ok(job) => job,
8192        Err(source) => {
8193            warn!(
8194                module_id,
8195                error = %source,
8196                "could not create a job object; this module's helper processes will not be \
8197                 reaped on teardown"
8198            );
8199            // Resume regardless: leaving the child suspended would turn a
8200            // containment gap into a hung module.
8201            resume_suspended_child(pid, spec)?;
8202            return Ok(None);
8203        }
8204    };
8205
8206    if let Err(source) = job.assign(child) {
8207        warn!(
8208            module_id,
8209            error = %source,
8210            "could not assign the child to its job object; this module's helper processes \
8211             will not be reaped on teardown"
8212        );
8213        resume_suspended_child(pid, spec)?;
8214        return Ok(None);
8215    }
8216
8217    resume_suspended_child(pid, spec)?;
8218    Ok(Some(job))
8219}
8220
8221/// Resume a suspended child, killing it if it cannot be started.
8222///
8223/// A suspended process holds a pid and does nothing, so there is no useful
8224/// state to return: the caller gets an error and the spawn fails.
8225#[cfg(windows)]
8226fn resume_suspended_child(pid: u32, spec: &ModuleSpec) -> Result<(), SuperviseError> {
8227    if let Err(source) = subc_jobobject::resume_main_thread(pid) {
8228        // Kill it here rather than leaving a suspended process for the caller
8229        // to notice; `kill_on_drop` would eventually do this, but the module
8230        // would have been reported as running in between.
8231        let _ = std::process::Command::new("taskkill.exe")
8232            .args(["/PID", &pid.to_string(), "/T", "/F"])
8233            .stdin(Stdio::null())
8234            .stdout(Stdio::null())
8235            .stderr(Stdio::null())
8236            .status();
8237        return Err(SuperviseError::Spawn {
8238            program: spec.program.clone(),
8239            source,
8240            cgroup_path: None,
8241        });
8242    }
8243    Ok(())
8244}
8245
8246#[cfg(target_os = "linux")]
8247fn remove_module_cgroup(placement: &subc_cgroup::Placement, module_id: &str) {
8248    match placement.remove_module(module_id) {
8249        Ok(()) => debug!(module_id, "removed module cgroup after process exit"),
8250        Err(error) => warn!(
8251            module_id,
8252            error = %error,
8253            "could not remove module cgroup after process exit; continuing teardown"
8254        ),
8255    }
8256}
8257
8258#[cfg(target_os = "linux")]
8259async fn cleanup_reaped_cgroup(placement: &subc_cgroup::Placement, module_id: &str) {
8260    // Reaping the direct child is not proof its descendants exited. End the
8261    // residual tree and wait for the kernel's population fact before rmdir;
8262    // otherwise a successful parent wait leaks a directory on each restart.
8263    if matches!(
8264        subc_cgroup::kill_module(Some(placement), module_id),
8265        subc_cgroup::KillOutcome::Killed
8266    ) {
8267        if let Ok(path) = placement.module_path(module_id) {
8268            while std::fs::read_to_string(path.join("cgroup.events"))
8269                .ok()
8270                .is_some_and(|events| events.lines().any(|line| line == "populated 1"))
8271            {
8272                sleep(Duration::from_millis(1)).await;
8273            }
8274        }
8275    }
8276    remove_module_cgroup(placement, module_id);
8277}
8278
8279#[cfg(target_os = "linux")]
8280fn apply_cgroup_placement(
8281    command: &mut Command,
8282    spec: &ModuleSpec,
8283    path: &std::path::Path,
8284) -> Result<(), SuperviseError> {
8285    subc_cgroup::apply(command, path).map_err(|source| SuperviseError::Cgroup {
8286        module_id: spec.module_id.clone(),
8287        source,
8288    })
8289}
8290
8291fn capture_retention(spec: &ModuleSpec) -> Retention {
8292    let defaults = Retention::default();
8293    let value = |name: &str| {
8294        spec.env
8295            .iter()
8296            .rev()
8297            .find_map(|(key, value)| (key == name).then_some(value.as_str()))
8298    };
8299    Retention {
8300        max_file_mb: value(CAPTURE_MAX_FILE_MB_ENV)
8301            .and_then(|value| value.parse().ok())
8302            .unwrap_or(defaults.max_file_mb),
8303        keep: value(CAPTURE_KEEP_ENV)
8304            .and_then(|value| value.parse().ok())
8305            .unwrap_or(defaults.keep),
8306        max_age_days: value(CAPTURE_MAX_AGE_DAYS_ENV)
8307            .and_then(|value| value.parse().ok())
8308            .unwrap_or(defaults.max_age_days),
8309    }
8310}
8311
8312/// A fresh 256-bit CSPRNG launch nonce, lowercase hex. Used to bind a reserved
8313/// module's registration to the exact process the supervisor spawned.
8314fn generate_launch_nonce() -> Result<String, SuperviseError> {
8315    let mut bytes = [0u8; 32];
8316    getrandom::getrandom(&mut bytes).map_err(|source| SuperviseError::LaunchNonce {
8317        reason: source.to_string(),
8318    })?;
8319    let mut hex = String::with_capacity(64);
8320    for b in bytes {
8321        use std::fmt::Write;
8322        let _ = write!(hex, "{b:02x}");
8323    }
8324    Ok(hex)
8325}
8326
8327/// Constant-time byte comparison so a reserved-nonce mismatch leaks no timing
8328/// signal about how many leading bytes matched.
8329fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
8330    if a.len() != b.len() {
8331        return false;
8332    }
8333    let mut diff = 0u8;
8334    for (x, y) in a.iter().zip(b.iter()) {
8335        diff |= x ^ y;
8336    }
8337    diff == 0
8338}
8339
8340/// The kernel's image after an acknowledged exec, shared by ordinary launches
8341/// and privacy trampolines. PATH and shebang interpretation are kernel facts,
8342/// not identities inferred from a configured pathname.
8343#[cfg(not(windows))]
8344fn observe_spawned_image(pid: u32) -> Option<subc_os::Observation> {
8345    subc_os::Process::open(pid)
8346        .ok()
8347        .flatten()
8348        .and_then(|process| process.observe())
8349}
8350
8351/// Resolve once and spawn that exact path, so PATH lookup cannot choose a
8352/// different executable after the no-delete capture was taken.
8353#[cfg(windows)]
8354fn windows_spawn_capture(spec: &ModuleSpec) -> io::Result<subc_os::ExecutableCapture> {
8355    let mut program = spec.program.clone();
8356    if program.is_absolute() || program.components().count() > 1 {
8357        // An explicit path can name a PE image with no extension. Try that
8358        // exact file first, as Command does, before falling back to .exe.
8359        match subc_os::ExecutableCapture::open(&program) {
8360            Ok(capture) => return Ok(capture),
8361            Err(error)
8362                if error.kind() == io::ErrorKind::NotFound && program.extension().is_none() => {}
8363            Err(error) => return Err(error),
8364        }
8365        program.set_extension("exe");
8366        return subc_os::ExecutableCapture::open(&program);
8367    }
8368    if program.extension().is_none() {
8369        program.set_extension("exe");
8370    }
8371    let custom_path = spec
8372        .env
8373        .iter()
8374        .rev()
8375        .find(|(key, _)| key.eq_ignore_ascii_case("PATH"));
8376    let mut directories = Vec::new();
8377    // These are Windows' default executable search locations. A configured
8378    // PATH still overrides the inherited PATH, not the system directories.
8379    if let Some(parent) = std::env::current_exe()?.parent() {
8380        directories.push(parent.to_path_buf());
8381    }
8382    directories.push(std::env::current_dir()?);
8383    if let Some(windows) = std::env::var_os("SystemRoot") {
8384        let windows = PathBuf::from(windows);
8385        directories.push(windows.join("System32"));
8386        directories.push(windows.clone());
8387    }
8388    let path = custom_path
8389        .map(|(_, value)| std::ffi::OsString::from(value))
8390        .or_else(|| std::env::var_os("PATH"));
8391    if let Some(path) = path {
8392        directories.extend(std::env::split_paths(&path));
8393    }
8394    for directory in directories {
8395        match subc_os::ExecutableCapture::open(&directory.join(&program)) {
8396            Ok(capture) => return Ok(capture),
8397            Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
8398            Err(error) => return Err(error),
8399        }
8400    }
8401    Err(io::Error::new(
8402        io::ErrorKind::NotFound,
8403        "resolved executable not found",
8404    ))
8405}
8406
8407fn spawn_and_mark_running(
8408    spec: &ModuleSpec,
8409    runtime: &SupervisorRuntimeConfig,
8410    snapshot: &SharedSnapshot,
8411) -> Result<SupervisedChild, SuperviseError> {
8412    let child = spawn_child(
8413        spec,
8414        runtime.connection_file_path.as_deref(),
8415        runtime.supervisor_handle.as_ref(),
8416        &runtime.stderr_ring,
8417        runtime.capture_logs_dir.as_deref(),
8418        &runtime.child_roster,
8419        #[cfg(target_os = "linux")]
8420        runtime.cgroup_placement.as_ref(),
8421    )?;
8422    set_running(snapshot, &child, &spec.module_id, &runtime.spawn_events)?;
8423    Ok(child)
8424}
8425
8426enum RegistrationWaitOutcome {
8427    Registered,
8428    Exited(ExitReport),
8429    TimedOut,
8430}
8431
8432struct ReloadRegistrationFailure {
8433    exit_report: ExitReport,
8434    reason: String,
8435}
8436
8437#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8438enum BusyGaugeObservation {
8439    Quiescent,
8440    Busy,
8441    Omitted,
8442}
8443
8444fn busy_gauge_observation(metrics: Option<&Value>, gauges: &[String]) -> BusyGaugeObservation {
8445    let Some(metrics) = metrics.and_then(Value::as_object) else {
8446        return BusyGaugeObservation::Omitted;
8447    };
8448    let mut sum = 0u128;
8449    for gauge in gauges {
8450        let Some(value) = metrics.get(gauge) else {
8451            return BusyGaugeObservation::Omitted;
8452        };
8453        let Some(value) = value.as_u64() else {
8454            return BusyGaugeObservation::Busy;
8455        };
8456        sum = sum.saturating_add(u128::from(value));
8457    }
8458    if sum == 0 {
8459        BusyGaugeObservation::Quiescent
8460    } else {
8461        BusyGaugeObservation::Busy
8462    }
8463}
8464
8465fn declared_busy_gauges(
8466    registry: &Registry,
8467    module_id: &str,
8468) -> Result<Vec<String>, SuperviseError> {
8469    busy_gauges_of(
8470        registry
8471            .get_module(module_id)
8472            .map_err(SuperviseError::Registry)?,
8473    )
8474}
8475
8476/// [`declared_busy_gauges`] for the registration a connection holds, in any
8477/// slot: after cutover the incumbent is no longer the id's active
8478/// registration, and its own manifest is the one that names its gauges.
8479fn declared_busy_gauges_for_connection(
8480    registry: &Registry,
8481    connection_id: ConnectionId,
8482) -> Result<Vec<String>, SuperviseError> {
8483    busy_gauges_of(
8484        registry
8485            .get_module_by_connection(connection_id)
8486            .map_err(SuperviseError::Registry)?,
8487    )
8488}
8489
8490fn busy_gauges_of(
8491    registration: Option<crate::registry::ModuleRegistration>,
8492) -> Result<Vec<String>, SuperviseError> {
8493    let Some(registration) = registration else {
8494        return Ok(Vec::new());
8495    };
8496    let Some(self_signals) = registration.manifest.self_signals else {
8497        return Ok(Vec::new());
8498    };
8499
8500    let mut gauges = Vec::new();
8501    for declaration in self_signals {
8502        if declaration.kind != SelfSignalKind::Busy {
8503            continue;
8504        }
8505        match declaration.anchored_to {
8506            SignalAnchor::HealthGauges { gauges: declared } if !declared.is_empty() => {
8507                gauges.extend(declared)
8508            }
8509            _ => {
8510                // An invalid Busy anchor is fail-safe: the empty name cannot be
8511                // present in a conforming health report, so this drain stays busy.
8512                gauges.push(String::new());
8513            }
8514        }
8515    }
8516    Ok(gauges)
8517}
8518
8519/// Wait for `endpoint` to have nothing in flight and, when the module declares
8520/// busy gauges, for a health probe to report them quiet. The probe is addressed
8521/// by `scope`: a swap's superseded incumbent must be asked about its own
8522/// gauges, and by module id the probe would reach the promoted candidate.
8523async fn wait_for_forwarding_quiescence(
8524    forwarding: &ForwardingTable,
8525    module_id: &str,
8526    runtime: &SupervisorRuntimeConfig,
8527    endpoint: crate::ModuleEndpointId,
8528    deadline: Instant,
8529    busy_gauges: &[String],
8530    scope: DrainScope,
8531) -> Result<bool, SuperviseError> {
8532    let mut gauges_quiescent = busy_gauges.is_empty();
8533    let mut next_probe_at = Instant::now();
8534    let mut omission_counted = false;
8535
8536    loop {
8537        let now = Instant::now();
8538        if !busy_gauges.is_empty() && now >= next_probe_at && now < deadline {
8539            let report = match scope {
8540                DrainScope::Active => probe_module_health(module_id, runtime, Some(deadline)).await,
8541                DrainScope::Endpoint(endpoint) => {
8542                    probe_endpoint_health(endpoint, runtime, Some(deadline)).await
8543                }
8544            };
8545            gauges_quiescent = match report {
8546                Ok(report) => match busy_gauge_observation(report.metrics.as_ref(), busy_gauges) {
8547                    BusyGaugeObservation::Quiescent => true,
8548                    BusyGaugeObservation::Busy => false,
8549                    BusyGaugeObservation::Omitted => {
8550                        if !omission_counted {
8551                            forwarding
8552                                .counters()
8553                                .increment_drains_with_undeclared_gauge();
8554                            omission_counted = true;
8555                        }
8556                        false
8557                    }
8558                },
8559                Err(err) => {
8560                    warn!(
8561                        module_id,
8562                        error = %err,
8563                        "drain health.check did not produce declared busy gauges; treating module as busy"
8564                    );
8565                    false
8566                }
8567            };
8568            next_probe_at = Instant::now() + runtime.health.cadence.max(REGISTRY_RELEASE_POLL);
8569        }
8570
8571        let in_flight = forwarding
8572            .endpoint_in_flight_count(endpoint)
8573            .map_err(SuperviseError::Forwarding)?;
8574        if in_flight == 0 && gauges_quiescent {
8575            return Ok(true);
8576        }
8577
8578        let now = Instant::now();
8579        if now >= deadline {
8580            return Ok(false);
8581        }
8582        let mut wait = deadline
8583            .saturating_duration_since(now)
8584            .min(REGISTRY_RELEASE_POLL);
8585        if !busy_gauges.is_empty() {
8586            wait = wait.min(next_probe_at.saturating_duration_since(now));
8587        }
8588        sleep(wait).await;
8589    }
8590}
8591
8592/// The `route.closed` `drained` value implied by a quiescence-wait outcome.
8593///
8594/// `Ok` is always honest and passed straight through -- the wait actually measured
8595/// in-flight state. `Err` means the wait produced no measurement at all (the
8596/// forwarding table's lock was poisoned), so `false` is reported as the one honest
8597/// constant: the drain did not complete. Never recomputed from route state, never a
8598/// third "unknown" value -- the caller must still send a well-formed `route.closed`.
8599fn drained_after_quiescence_wait(wait_result: &Result<bool, SuperviseError>) -> bool {
8600    match wait_result {
8601        Ok(drained) => *drained,
8602        Err(_) => false,
8603    }
8604}
8605
8606fn send_route_goodbyes(forwarding: &ForwardingTable, released_routes: Vec<GoodbyeTarget>) {
8607    for released in released_routes {
8608        let frame = match Frame::build_with_version(
8609            released.negotiated_ver,
8610            FrameType::Goodbye,
8611            control_flags(),
8612            released.channel,
8613            released.epoch,
8614            0,
8615            Vec::new(),
8616        ) {
8617            Ok(frame) => frame,
8618            Err(err) => {
8619                warn!(
8620                    route_channel = released.channel,
8621                    error = %err,
8622                    "failed to build supervisor drain route GOODBYE frame"
8623                );
8624                continue;
8625            }
8626        };
8627        if !released.close_on_delivery_failure() {
8628            crate::forwarding::send_module_route_goodbye(
8629                &forwarding.counters(),
8630                &released.sink,
8631                frame,
8632                released.module_id.as_deref(),
8633                "supervisor drain",
8634            );
8635            continue;
8636        }
8637        if let Err(err) = released.sink.try_send(frame) {
8638            warn!(
8639                target_connection_id = released.connection_id.get(),
8640                route_channel = released.channel,
8641                error = %err,
8642                "supervisor drain route GOODBYE was not delivered to client; closing target connection"
8643            );
8644            let _ = forwarding.escalate_client_delivery_failure(
8645                released.connection_id,
8646                released.channel,
8647                released.epoch,
8648                CloseReason::new(
8649                    "route_goodbye_delivery_failed",
8650                    format!(
8651                        "failed to enqueue supervisor drain route GOODBYE for channel {}: {err}",
8652                        released.channel
8653                    ),
8654                ),
8655                crate::forwarding::UndeliveredFrame {
8656                    module_id: released.module_id.as_deref(),
8657                    sink: &released.sink,
8658                },
8659            );
8660        }
8661    }
8662}
8663
8664fn send_module_draining(
8665    module_id: &str,
8666    reason: RouteCloseReason,
8667    deadline_ms: u64,
8668    target: &ModuleDrainTarget,
8669) {
8670    let body = match serde_json::to_vec(&ModuleControlCommand::Draining {
8671        reason,
8672        deadline_ms,
8673    }) {
8674        Ok(body) => body,
8675        Err(err) => {
8676            warn!(
8677                module_id,
8678                error = %err,
8679                "failed to encode module draining command"
8680            );
8681            return;
8682        }
8683    };
8684    let frame = match Frame::build_with_version(
8685        target.negotiated_ver,
8686        FrameType::Push,
8687        control_flags(),
8688        0,
8689        0,
8690        0,
8691        body,
8692    ) {
8693        Ok(frame) => frame,
8694        Err(err) => {
8695            warn!(
8696                module_id,
8697                error = %err,
8698                "failed to build module draining command frame"
8699            );
8700            return;
8701        }
8702    };
8703    if let Err(err) = target.sink.try_send(frame) {
8704        warn!(
8705            module_id,
8706            target_connection_id = target.endpoint.connection_id.get(),
8707            error = %err,
8708            "module draining command was not delivered to peer"
8709        );
8710    }
8711}
8712
8713/// The channel-0 GOODBYE that tells a module its stop is planned.
8714fn module_goodbye_frame(module_id: &str, negotiated_ver: u8) -> Option<Frame> {
8715    match Frame::build_with_version(
8716        negotiated_ver,
8717        FrameType::Goodbye,
8718        control_flags(),
8719        0,
8720        0,
8721        0,
8722        Vec::new(),
8723    ) {
8724        Ok(frame) => Some(frame),
8725        Err(err) => {
8726            warn!(
8727                module_id,
8728                error = %err,
8729                "failed to build module GOODBYE frame"
8730            );
8731            None
8732        }
8733    }
8734}
8735
8736/// Send every registered module connection its module GOODBYE at daemon
8737/// shutdown, then request that connection's close.
8738///
8739/// A module tells a planned stop from a lost daemon by whether a GOODBYE came
8740/// before EOF, so the GOODBYE must reach the socket before the close. A close
8741/// request does not wait for the connection's queued frames: its writer gets a
8742/// bounded grace after the close, is aborted if it overruns it, and the daemon
8743/// process may exit before that grace ends. So with `wait_for_flush`, each
8744/// connection is closed only after its writer has acknowledged writing the
8745/// GOODBYE, or once a short shared budget runs out, so one module that is not
8746/// reading cannot hold up the others or the shutdown. Without it the GOODBYEs
8747/// are only queued, for a shutdown the operator has told to stop waiting.
8748/// A connection that is already gone is skipped.
8749#[cfg(unix)]
8750async fn send_module_goodbyes_for_daemon_shutdown(
8751    forwarding: &Arc<ForwardingTable>,
8752    reason: &CloseReason,
8753    wait_for_flush: bool,
8754) {
8755    const GOODBYE_BUDGET: Duration = Duration::from_millis(500);
8756    let targets = match forwarding.module_connections() {
8757        Ok(targets) => targets,
8758        Err(err) => {
8759            warn!(error = %err, "could not list module connections for shutdown GOODBYE");
8760            return;
8761        }
8762    };
8763    let deadline = Instant::now() + GOODBYE_BUDGET;
8764    let mut sends = tokio::task::JoinSet::new();
8765    for target in targets {
8766        let Some(frame) = module_goodbye_frame(&target.module_id, target.negotiated_ver) else {
8767            continue;
8768        };
8769        if !wait_for_flush {
8770            if let Err(err) = target.sink.try_send(frame) {
8771                debug!(
8772                    module_id = %target.module_id,
8773                    error = %err,
8774                    "shutdown module GOODBYE was not queued"
8775                );
8776            }
8777            continue;
8778        }
8779        let forwarding = Arc::clone(forwarding);
8780        let reason = reason.clone();
8781        sends.spawn(async move {
8782            match timeout_at(deadline, target.sink.send_flushed(frame)).await {
8783                Ok(Ok(())) => {}
8784                Ok(Err(err)) => debug!(
8785                    module_id = %target.module_id,
8786                    error = %err,
8787                    "module connection closed before its shutdown GOODBYE was written"
8788                ),
8789                Err(_) => warn!(
8790                    module_id = %target.module_id,
8791                    budget = ?GOODBYE_BUDGET,
8792                    "shutdown module GOODBYE was not written within its budget; closing anyway"
8793                ),
8794            }
8795            forwarding.request_connection_close(target.endpoint.connection_id, reason);
8796        });
8797    }
8798    // Every task ends by the shared deadline, so this wait is bounded too.
8799    while sends.join_next().await.is_some() {}
8800}
8801
8802fn send_module_goodbye(module_id: &str, forwarding: &ForwardingTable, target: &ModuleDrainTarget) {
8803    let Some(frame) = module_goodbye_frame(module_id, target.negotiated_ver) else {
8804        return;
8805    };
8806    if let Err(err) = target.sink.try_send(frame) {
8807        warn!(
8808            module_id,
8809            target_connection_id = target.endpoint.connection_id.get(),
8810            error = %err,
8811            "supervisor drain module GOODBYE was not delivered to peer; closing module connection"
8812        );
8813        forwarding.request_connection_close(
8814            target.endpoint.connection_id,
8815            CloseReason::new(
8816                "module_goodbye_delivery_failed",
8817                format!("failed to enqueue supervisor drain module GOODBYE for module '{module_id}': {err}"),
8818            ),
8819        );
8820    }
8821}
8822
8823#[derive(Clone, Copy)]
8824struct ForwardingDrainContext<'a> {
8825    spec: &'a ModuleSpec,
8826    runtime: &'a SupervisorRuntimeConfig,
8827    registry: &'a Registry,
8828    scope: DrainScope,
8829}
8830
8831/// Which process a forwarding drain addresses.
8832#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8833enum DrainScope {
8834    /// Whatever endpoint is active for the module id: every plain stop,
8835    /// restart and reload. Also moves the module's state to `Draining`.
8836    Active,
8837    /// One specific endpoint: a swap's incumbent after cutover. Draining it by
8838    /// module id would resolve to the promoted candidate and leave neither
8839    /// process routable. The module's state is left alone, since the promoted
8840    /// candidate is what it describes and that process is running.
8841    Endpoint(crate::ModuleEndpointId),
8842}
8843
8844/// Whether a child being drained has already been asked to stop by the time
8845/// its drain wait starts.
8846///
8847/// The drain wait is the same budget whatever this says. What it decides is
8848/// whether the supervisor must ask by signal before that wait begins: a child
8849/// that nobody asked will sit out the whole budget and then be SIGKILLed,
8850/// healthy or not.
8851#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8852enum StopNotice {
8853    /// The module was sent `module.draining` and a module GOODBYE over its own
8854    /// registered connection, and stops itself.
8855    SentOverConnection,
8856    /// The forwarding drain found no registered connection for the module: a
8857    /// subc child spawned moments ago that has not sent HELLO yet, or a
8858    /// `protocol: "none"` child, which never registers.
8859    NoConnection,
8860    /// This path sends nothing over the module's connection: the supervisor has
8861    /// no forwarding table, or the caller stops the child without a forwarding
8862    /// drain.
8863    NotSent,
8864}
8865
8866async fn begin_forwarding_drain(
8867    spec: &ModuleSpec,
8868    runtime: &SupervisorRuntimeConfig,
8869    registry: &Registry,
8870    snapshot: &SharedSnapshot,
8871    enabled: Option<bool>,
8872    reason: RouteCloseReason,
8873) -> Result<StopNotice, SuperviseError> {
8874    let Some(forwarding) = runtime.forwarding.as_ref() else {
8875        return Err(SuperviseError::ReloadUnavailable {
8876            module_id: spec.module_id.clone(),
8877            reason: "supervisor was not configured with a forwarding table".to_string(),
8878        });
8879    };
8880
8881    begin_forwarding_drain_with(
8882        forwarding,
8883        ForwardingDrainContext {
8884            spec,
8885            runtime,
8886            registry,
8887            scope: DrainScope::Active,
8888        },
8889        snapshot,
8890        enabled,
8891        reason,
8892        runtime.drain_timeout,
8893    )
8894    .await
8895}
8896
8897async fn begin_forwarding_drain_if_configured(
8898    spec: &ModuleSpec,
8899    runtime: &SupervisorRuntimeConfig,
8900    registry: &Registry,
8901    snapshot: &SharedSnapshot,
8902    enabled: Option<bool>,
8903    reason: RouteCloseReason,
8904) -> Result<StopNotice, SuperviseError> {
8905    begin_forwarding_drain_with_timeout(
8906        spec,
8907        runtime,
8908        registry,
8909        snapshot,
8910        enabled,
8911        reason,
8912        runtime.drain_timeout,
8913    )
8914    .await
8915}
8916
8917/// Like [`begin_forwarding_drain_if_configured`] but with an explicit drain
8918/// budget, for paths where the operator overrides the module's configured one
8919/// (`supervisor.restart{drain_timeout_ms}`).
8920async fn begin_forwarding_drain_with_timeout(
8921    spec: &ModuleSpec,
8922    runtime: &SupervisorRuntimeConfig,
8923    registry: &Registry,
8924    snapshot: &SharedSnapshot,
8925    enabled: Option<bool>,
8926    reason: RouteCloseReason,
8927    drain_timeout: Duration,
8928) -> Result<StopNotice, SuperviseError> {
8929    let Some(forwarding) = runtime.forwarding.as_ref() else {
8930        return Ok(StopNotice::NotSent);
8931    };
8932
8933    begin_forwarding_drain_with(
8934        forwarding,
8935        ForwardingDrainContext {
8936            spec,
8937            runtime,
8938            registry,
8939            scope: DrainScope::Active,
8940        },
8941        snapshot,
8942        enabled,
8943        reason,
8944        drain_timeout,
8945    )
8946    .await
8947}
8948
8949async fn begin_forwarding_drain_with(
8950    forwarding: &ForwardingTable,
8951    context: ForwardingDrainContext<'_>,
8952    snapshot: &SharedSnapshot,
8953    enabled: Option<bool>,
8954    reason: RouteCloseReason,
8955    drain_timeout: Duration,
8956) -> Result<StopNotice, SuperviseError> {
8957    let ForwardingDrainContext {
8958        spec,
8959        runtime,
8960        registry,
8961        scope,
8962    } = context;
8963    debug_assert_ne!(reason, RouteCloseReason::Crash);
8964    let terminal = matches!(reason, RouteCloseReason::Disable);
8965    let drain_started_at = Instant::now();
8966    let drain_deadline = drain_started_at + drain_timeout;
8967    let deadline_ms =
8968        unix_ms_now().saturating_add(u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX));
8969    let busy_gauges = match scope {
8970        DrainScope::Active => declared_busy_gauges(registry, &spec.module_id)?,
8971        DrainScope::Endpoint(endpoint) => {
8972            declared_busy_gauges_for_connection(registry, endpoint.connection_id)?
8973        }
8974    };
8975
8976    // Admission gate first: route.open/commit and route REQUEST admission are closed
8977    // before the first quiescence check, so the outstanding count can only fall.
8978    let gate_started = Instant::now();
8979    let drain_target = match scope {
8980        DrainScope::Active => forwarding.begin_module_drain(&spec.module_id, reason),
8981        DrainScope::Endpoint(endpoint) => forwarding.begin_endpoint_drain(endpoint, reason),
8982    }
8983    .map_err(SuperviseError::Forwarding)?;
8984    // The instant admission closed, and how long taking the forwarding write
8985    // lock to close it took. The timeout line reports only the quiescence
8986    // wait, so without this a drain that started late looked like one that
8987    // started on time.
8988    info!(
8989        module_id = %spec.module_id,
8990        ?reason,
8991        gate_ms = u64::try_from(gate_started.elapsed().as_millis()).unwrap_or(u64::MAX),
8992        connected = drain_target.is_some(),
8993        "module drain began; route admission closed"
8994    );
8995    if scope == DrainScope::Active {
8996        update_snapshot(snapshot, Some(&spec.module_id), |state| {
8997            state.state = ModuleState::Draining;
8998            state.draining_to_replace =
8999                matches!(reason, RouteCloseReason::Restart | RouteCloseReason::Reload);
9000            if let Some(enabled) = enabled {
9001                state.enabled = enabled;
9002            }
9003        })?;
9004    }
9005
9006    let Some(target) = drain_target.as_ref() else {
9007        // Nothing was sent: the module has no registered connection to carry
9008        // `module.draining` or a GOODBYE. The caller must not assume the child
9009        // was asked to stop.
9010        return Ok(StopNotice::NoConnection);
9011    };
9012    {
9013        send_module_draining(&spec.module_id, reason, deadline_ms, target);
9014        let routes = forwarding
9015            .endpoint_routes(target.endpoint)
9016            .map_err(SuperviseError::Forwarding)?;
9017        let routes_notified = routes.len();
9018        crate::control::send_route_control_pushes(
9019            forwarding,
9020            routes.clone(),
9021            ClientControlPush::RouteClosing {
9022                module_id: spec.module_id.clone(),
9023                channels: Vec::new(),
9024                reason,
9025            },
9026        );
9027        send_route_goodbyes(forwarding, target.abandoned_bindings.clone());
9028
9029        // `route.closing` was just sent above: from here on every return path,
9030        // including an early one, MUST send `route.closed` before propagating
9031        // anything else. A client holds `closing` as a promise that a verdict is
9032        // coming; leaving early without `closed` strands it waiting forever, since
9033        // `closing` carries no timeout of its own.
9034        let wait_result = wait_for_forwarding_quiescence(
9035            forwarding,
9036            &spec.module_id,
9037            runtime,
9038            target.endpoint,
9039            drain_deadline,
9040            &busy_gauges,
9041            scope,
9042        )
9043        .await;
9044        let drained = drained_after_quiescence_wait(&wait_result);
9045        if let Err(err) = &wait_result {
9046            error!(
9047                module_id = %spec.module_id,
9048                ?reason,
9049                error = %err,
9050                "forwarding quiescence wait failed after route.closing; forcing route.closed(drained: false) so the client is not left waiting on an unfulfilled promise"
9051            );
9052        } else if !drained {
9053            // Name what the drain waited on. Without it the line says only that
9054            // something did not settle, and "one wedged call" and "every
9055            // session's held stream" read the same; the first is a module bug,
9056            // the second is a module that should end its streams on
9057            // module.draining. Read before teardown releases the routes.
9058            let holdouts = forwarding
9059                .endpoint_drain_holdouts(target.endpoint)
9060                .unwrap_or_default();
9061            warn!(
9062                module_id = %spec.module_id,
9063                waited = ?drain_timeout,
9064                ?reason,
9065                held_requests = holdouts.requests,
9066                held_routes = holdouts.routes,
9067                total_routes = holdouts.total_routes,
9068                top_connections = ?holdouts.top_connections,
9069                // `module_channel:corr`, so the module can find each held request
9070                // in its own log; capped, so `held_requests` is the full count.
9071                held = %holdouts
9072                    .held
9073                    .iter()
9074                    .map(|(channel, corr)| format!("{channel}:{corr}"))
9075                    .collect::<Vec<_>>()
9076                    .join(","),
9077                "route drain timed out before request quiescence; forcing teardown"
9078            );
9079        }
9080        crate::control::send_route_control_pushes(
9081            forwarding,
9082            routes,
9083            ClientControlPush::RouteClosed {
9084                module_id: spec.module_id.clone(),
9085                channels: Vec::new(),
9086                reason,
9087                drained,
9088                abandoned: target.abandoned_bindings.len() as u32,
9089                excluded_subscriptions: target.excluded_subscriptions,
9090                terminal: Some(terminal),
9091            },
9092        );
9093        wait_result?;
9094
9095        // `route.closed` has now been sent unconditionally above. From here the
9096        // remaining steps are cleanup (route + module GOODBYE) rather than a
9097        // promise the client is waiting on, but a lock-poisoned
9098        // `release_module_endpoint_routes` would otherwise skip the module
9099        // GOODBYE silently too -- send it before propagating the error.
9100        let released_routes = match forwarding.release_module_endpoint_routes(target.endpoint) {
9101            Ok(routes) => routes,
9102            Err(err) => {
9103                warn!(
9104                    module_id = %spec.module_id,
9105                    ?reason,
9106                    error = %err,
9107                    "failed to release module endpoint routes after route.closed; module GOODBYE will still be sent"
9108                );
9109                send_module_goodbye(&spec.module_id, forwarding, target);
9110                return Err(SuperviseError::Forwarding(err));
9111            }
9112        };
9113        let route_goodbye_count = released_routes.len();
9114        send_route_goodbyes(forwarding, released_routes);
9115        send_module_goodbye(&spec.module_id, forwarding, target);
9116
9117        // The drain's happy path was previously silent: every emission above is
9118        // best-effort with only its failure arm logged, so "were consumers told"
9119        // was unprovable from the daemon log (surfaced by a 30-minute consumer
9120        // hang where the open question was exactly whether teardown notice went
9121        // out). One summary line makes that class decidable in one grep.
9122        info!(
9123            module_id = %spec.module_id,
9124            ?reason,
9125            routes_notified,
9126            route_goodbyes = route_goodbye_count,
9127            abandoned_reservations = target.abandoned_bindings.len(),
9128            excluded_subscriptions = target.excluded_subscriptions,
9129            drained,
9130            "module drain complete; consumers notified via route.closing/route.closed pushes and per-route GOODBYE frames"
9131        );
9132    }
9133
9134    Ok(StopNotice::SentOverConnection)
9135}
9136
9137/// Wait for the freshly spawned child to take the ACTIVE slot for `module_id`,
9138/// the only slot a plain (non-swap) spawn can register into.
9139async fn wait_for_registration_after_reload(
9140    registry: &Registry,
9141    module_id: &str,
9142    snapshot: &SharedSnapshot,
9143    child: &mut SupervisedChild,
9144    wait: Duration,
9145) -> Result<RegistrationWaitOutcome, SuperviseError> {
9146    wait_for_slot_registration(
9147        registry,
9148        crate::registry::RegistrationSlot::Active(module_id),
9149        module_id,
9150        snapshot,
9151        child,
9152        wait,
9153    )
9154    .await
9155}
9156
9157/// Wait for `child` to register into `slot`, or to exit, or for `wait` to pass.
9158///
9159/// Keyed on the slot rather than the bare module id because during a swap the
9160/// id's active slot is already held by the incumbent: an id-keyed wait would
9161/// report the incumbent's registration as the candidate's and a candidate that
9162/// never registers would look registered. A swap candidate waits on
9163/// `crate::registry::RegistrationSlot::Candidate`.
9164async fn wait_for_slot_registration(
9165    registry: &Registry,
9166    slot: crate::registry::RegistrationSlot<'_>,
9167    module_id: &str,
9168    snapshot: &SharedSnapshot,
9169    child: &mut SupervisedChild,
9170    wait: Duration,
9171) -> Result<RegistrationWaitOutcome, SuperviseError> {
9172    let deadline = Instant::now() + wait;
9173    loop {
9174        if registry
9175            .registration(slot)
9176            .map_err(SuperviseError::Registry)?
9177            .is_some()
9178        {
9179            return Ok(RegistrationWaitOutcome::Registered);
9180        }
9181
9182        let now = Instant::now();
9183        if now >= deadline {
9184            return Ok(RegistrationWaitOutcome::TimedOut);
9185        }
9186        let remaining = deadline.saturating_duration_since(now);
9187        let poll = remaining.min(REGISTRY_RELEASE_POLL);
9188
9189        tokio::select! {
9190            wait_result = child.wait() => {
9191                let status = wait_result.map_err(|source| SuperviseError::Wait {
9192                    module_id: module_id.to_string(),
9193                    source,
9194                })?;
9195                return Ok(RegistrationWaitOutcome::Exited(classify_reaped_child_exit(
9196                    snapshot,
9197                    child,
9198                    &status,
9199                )));
9200            }
9201            _ = sleep(poll) => {}
9202        }
9203    }
9204}
9205
9206fn registration_failure_exit_report(mut exit_report: ExitReport) -> ExitReport {
9207    // A replacement process that exits before HELLO did not provide service, even
9208    // if it used status 0. Count it against the restart cap as a new-binary failure.
9209    if exit_report.kind != ExitKind::DeliberateSeverance {
9210        exit_report.kind = ExitKind::Crash;
9211    }
9212    exit_report
9213}
9214
9215async fn handle_reload_child_registration_failure(
9216    spec: &ModuleSpec,
9217    runtime: &SupervisorRuntimeConfig,
9218    registry: &Registry,
9219    process_liveness: &SupervisorProcessLiveness,
9220    snapshot: &SharedSnapshot,
9221    _child: &mut Option<SupervisedChild>,
9222    failure: ReloadRegistrationFailure,
9223) -> Result<(), SuperviseError> {
9224    let ReloadRegistrationFailure {
9225        exit_report,
9226        reason,
9227    } = failure;
9228    match on_child_exit(
9229        spec,
9230        runtime.restart_policy,
9231        registry,
9232        snapshot,
9233        &runtime.terminal_ring,
9234        &runtime.spawn_events,
9235        &runtime.child_roster,
9236        exit_report,
9237    )
9238    .await
9239    {
9240        NextAction::Stop {
9241            registration_released,
9242        } => {
9243            if registration_released {
9244                process_liveness.untrack_if_current(&spec.module_id, snapshot);
9245            }
9246        }
9247        NextAction::Restart { schedule } => {
9248            let delay = schedule.map_or(runtime.restart_policy.delay_for_restart(0), |schedule| {
9249                schedule.delay
9250            });
9251            if let Some(schedule) = schedule {
9252                log_crash_respawn(&spec.module_id, schedule);
9253            }
9254            schedule_respawn(
9255                runtime,
9256                snapshot,
9257                &spec.module_id,
9258                delay,
9259                RespawnKind::Spawn,
9260            )?;
9261        }
9262    }
9263    Err(SuperviseError::ReloadFailed {
9264        module_id: spec.module_id.clone(),
9265        reason,
9266    })
9267}
9268
9269async fn handle_reload_spawn_failure(
9270    spec: &ModuleSpec,
9271    runtime: &SupervisorRuntimeConfig,
9272    process_liveness: &SupervisorProcessLiveness,
9273    snapshot: &SharedSnapshot,
9274    _child: &mut Option<SupervisedChild>,
9275    reason: String,
9276) -> Result<(), SuperviseError> {
9277    let now = Instant::now();
9278    let mut schedule = None;
9279    update_snapshot(snapshot, Some(&spec.module_id), |state| {
9280        clear_current_process_facts(state);
9281        if state.enabled {
9282            schedule = state.next_crash_restart(&runtime.restart_policy, now);
9283            state.state = if schedule.is_some() {
9284                ModuleState::Restarting
9285            } else {
9286                ModuleState::Failed
9287            };
9288        } else {
9289            state.state = ModuleState::Disabled;
9290        }
9291    })?;
9292    if let Some(schedule) = schedule {
9293        schedule_respawn(
9294            runtime,
9295            snapshot,
9296            &spec.module_id,
9297            schedule.delay,
9298            RespawnKind::Spawn,
9299        )?;
9300    } else {
9301        process_liveness.untrack_if_current(&spec.module_id, snapshot);
9302    }
9303    Err(SuperviseError::ReloadFailed {
9304        module_id: spec.module_id.clone(),
9305        reason,
9306    })
9307}
9308
9309fn control_flags() -> Flags {
9310    Flags::new(false, Priority::Passive, false)
9311}
9312
9313#[allow(clippy::too_many_arguments)]
9314async fn drain_optional_child(
9315    module_id: &str,
9316    protocol: ModuleProtocol,
9317    stop_notice: StopNotice,
9318    registry: &Registry,
9319    forwarding: Option<&ForwardingTable>,
9320    snapshot: &SharedSnapshot,
9321    terminal_ring: &Arc<Mutex<TerminalRing>>,
9322    spawn_events: &SpawnEventFeed,
9323    child: &mut Option<SupervisedChild>,
9324    drain_timeout: Duration,
9325    final_state: ModuleState,
9326    enabled: Option<bool>,
9327) -> Result<(), SuperviseError> {
9328    if let Some(child) = child.take() {
9329        drain_child_to_state(
9330            module_id,
9331            protocol,
9332            stop_notice,
9333            registry,
9334            forwarding,
9335            snapshot,
9336            terminal_ring,
9337            spawn_events,
9338            child,
9339            drain_timeout,
9340            final_state,
9341            enabled,
9342        )
9343        .await
9344    } else {
9345        update_snapshot(snapshot, Some(module_id), |state| {
9346            state.state = final_state;
9347            if let Some(enabled) = enabled {
9348                state.enabled = enabled;
9349            }
9350            clear_current_process_facts(state);
9351        })?;
9352        release_dead_registration(registry, forwarding, snapshot, module_id).await
9353    }
9354}
9355
9356#[allow(clippy::too_many_arguments)]
9357async fn drain_child_to_state(
9358    module_id: &str,
9359    _protocol: ModuleProtocol,
9360    stop_notice: StopNotice,
9361    registry: &Registry,
9362    forwarding: Option<&ForwardingTable>,
9363    snapshot: &SharedSnapshot,
9364    terminal_ring: &Arc<Mutex<TerminalRing>>,
9365    spawn_events: &SpawnEventFeed,
9366    mut child: SupervisedChild,
9367    drain_timeout: Duration,
9368    final_state: ModuleState,
9369    enabled: Option<bool>,
9370) -> Result<(), SuperviseError> {
9371    let protocol = child.protocol;
9372    update_snapshot(snapshot, Some(module_id), |state| {
9373        state.state = ModuleState::Draining;
9374        state.draining_to_replace = final_state == ModuleState::Restarting;
9375        if let Some(enabled) = enabled {
9376            state.enabled = enabled;
9377        }
9378    })?;
9379
9380    // The wait below is the same budget in every case; what differs is
9381    // whether anything has ASKED the child to stop before it starts. Only a
9382    // forwarding drain that reached the module's registered connection has
9383    // (`module.draining`, then a module GOODBYE). Every other child was told
9384    // nothing: a `protocol: "none"` module, which never registers; a subc
9385    // module spawned moments ago that has not sent HELLO yet; or a stop that
9386    // runs no forwarding drain. Without a signal the budget is only a delay
9387    // in front of SIGKILL -- and the not-yet-registered child is the worst
9388    // case, because it registers into a module that is already draining,
9389    // is never told, and is killed while healthy.
9390    if stop_notice != StopNotice::SentOverConnection {
9391        if protocol == ModuleProtocol::Subc && stop_notice == StopNotice::NoConnection {
9392            info!(
9393                module_id,
9394                pid = child.pid,
9395                budget_ms = u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX),
9396                "module has no connection yet; requesting stop by signal"
9397            );
9398        }
9399        request_graceful_stop(module_id, &child);
9400    }
9401
9402    let exit_report = match timeout(drain_timeout, child.wait()).await {
9403        Ok(Ok(status)) => classify_reaped_child_exit(snapshot, &child, &status),
9404        Ok(Err(source)) => {
9405            fail_after_wait_error(
9406                module_id,
9407                snapshot,
9408                terminal_ring,
9409                spawn_events,
9410                format!("failed to wait during drain: {source}"),
9411            );
9412            return Err(SuperviseError::Wait {
9413                module_id: module_id.to_string(),
9414                source,
9415            });
9416        }
9417        Err(_) => {
9418            // Mirror the sibling arm above: state is already `Draining`, and an
9419            // error propagated from here would strand it there -- a state
9420            // `set_enabled(true)` cannot heal (`revive_terminal` matches only
9421            // `Failed | Stopped`), leaving an operator Restart as the only exit.
9422            // `Failed` before `?` keeps the module operator-visible and
9423            // revivable. Trigger is an ESRCH race (process exits between the
9424            // drain timeout firing and the kill) or a post-kill wait failure
9425            // (issue #34).
9426            //
9427            // Logged because the kill is otherwise visible only as signal 9 in
9428            // the terminal ring, and the budget it follows can be long enough
9429            // that consumers see a stretch of refusals with no stated cause.
9430            warn!(
9431                module_id,
9432                pid = child.pid,
9433                budget_ms = u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX),
9434                reason = ?final_state,
9435                ?stop_notice,
9436                "drain budget expired before the module exited; killing it"
9437            );
9438            child.start_kill().map_err(|source| {
9439                fail_after_wait_error(
9440                    module_id,
9441                    snapshot,
9442                    terminal_ring,
9443                    spawn_events,
9444                    format!("failed to kill during drain: {source}"),
9445                );
9446                SuperviseError::Kill {
9447                    module_id: module_id.to_string(),
9448                    source,
9449                }
9450            })?;
9451            let status = child.wait().await.map_err(|source| {
9452                fail_after_wait_error(
9453                    module_id,
9454                    snapshot,
9455                    terminal_ring,
9456                    spawn_events,
9457                    format!("failed to wait after drain kill: {source}"),
9458                );
9459                SuperviseError::Wait {
9460                    module_id: module_id.to_string(),
9461                    source,
9462                }
9463            })?;
9464            classify_reaped_child_exit(snapshot, &child, &status)
9465        }
9466    };
9467
9468    let detail = lock_snapshot(snapshot)?.drain_disposition_detail.take();
9469    publish_after_terminal_record(
9470        module_id,
9471        || {
9472            record_terminal_with_detail(
9473                module_id,
9474                terminal_ring,
9475                spawn_events,
9476                &exit_report,
9477                terminal_disposition(final_state),
9478                detail,
9479            )
9480        },
9481        || {
9482            update_snapshot(snapshot, Some(module_id), |state| {
9483                state.state = final_state;
9484                if let Some(enabled) = enabled {
9485                    state.enabled = enabled;
9486                }
9487                clear_current_process_facts(state);
9488                state.last_exit = Some(exit_report.clone());
9489                if exit_report.kind == ExitKind::DeliberateSeverance {
9490                    state.lifetime_restarts += 1;
9491                }
9492            })
9493        },
9494    )?;
9495    child.drain_stderr(module_id).await;
9496
9497    release_dead_registration(registry, forwarding, snapshot, module_id).await
9498}
9499
9500/// Ask a child that nothing else has asked to stop, by signal.
9501///
9502/// A registered subc module is asked over its own connection: the drain sends
9503/// `route.closing`/`route.closed` to its consumers, a GOODBYE per route, then a
9504/// module GOODBYE, and the module stops itself. A module that speaks no subc
9505/// wire receives none of that, and neither does a subc module that has not
9506/// registered yet, so for them the drain budget would be pure delay in front of
9507/// a SIGKILL -- and for a process with a store to flush (JetStream is the
9508/// reason `protocol: "none"` exists) a SIGKILL turns every ordinary teardown
9509/// into a recovery on the next start.
9510///
9511/// NEVER CALLED FOR A MODULE THAT WAS TOLD OVER ITS CONNECTION, and that is a
9512/// rule rather than an optimisation: that module's graceful stop is already
9513/// running by the time its child is drained, and a signal would race it.
9514///
9515/// Best-effort by construction. A child that has already exited is the ordinary
9516/// case rather than an error (the kill lands on a reaped or exiting pid), so a
9517/// failure is logged at debug and the wait-then-kill below still decides the
9518/// outcome.
9519#[cfg(unix)]
9520fn request_graceful_stop(module_id: &str, child: &SupervisedChild) {
9521    let Some(pid) = child
9522        .id()
9523        .and_then(|pid| i32::try_from(pid).ok())
9524        .and_then(rustix::process::Pid::from_raw)
9525    else {
9526        debug!(
9527            module_id,
9528            "no pid to signal for teardown; falling through to the drain wait"
9529        );
9530        return;
9531    };
9532    match rustix::process::kill_process(pid, rustix::process::Signal::TERM) {
9533        Ok(()) => debug!(
9534            module_id,
9535            "sent SIGTERM to a module nothing else asked to stop"
9536        ),
9537        Err(err) => debug!(
9538            module_id,
9539            error = %err,
9540            "SIGTERM to module failed; the drain wait and kill still apply"
9541        ),
9542    }
9543}
9544
9545/// Windows has no SIGTERM and no portable stand-in for one. The graceful stops
9546/// Windows does offer need cooperation this supervisor cannot assume: a console
9547/// control event requires sharing a console with the child, and `WM_CLOSE`
9548/// requires the child to pump a message loop. A supervised server process does
9549/// neither, so there is nothing to send and teardown is the wait followed by the
9550/// kill. Emulating a signal here would mean inventing a stop protocol, which is
9551/// the thing `protocol: "none"` exists to avoid.
9552#[cfg(not(unix))]
9553fn request_graceful_stop(module_id: &str, _child: &SupervisedChild) {
9554    debug!(
9555        module_id,
9556        "no graceful stop signal exists on this platform; teardown of a module nothing asked to stop waits, then kills"
9557    );
9558}
9559
9560fn terminal_disposition(final_state: ModuleState) -> TerminalDisposition {
9561    match final_state {
9562        ModuleState::Stopped => TerminalDisposition::Stopped,
9563        ModuleState::Disabled => TerminalDisposition::Disabled,
9564        ModuleState::Restarting => TerminalDisposition::Restarting,
9565        ModuleState::Failed => TerminalDisposition::Failed,
9566        ModuleState::Starting
9567        | ModuleState::Running
9568        | ModuleState::Unresponsive
9569        | ModuleState::Draining => {
9570            unreachable!("terminal exits only finish in terminal or restarting states")
9571        }
9572    }
9573}
9574
9575/// Release a reaped child's registration before allowing another spawn.
9576///
9577/// EOF is not a process-lifetime signal: an inherited socket can stay open
9578/// indefinitely, and serial frame dispatch can be waiting on egress instead of
9579/// reading EOF. After the normal release grace, request connection close (which
9580/// cancels both reads and dispatch), then allow one more release grace for the
9581/// connection guard's forwarding cleanup. Never evict a different connection.
9582async fn release_dead_registration(
9583    registry: &Registry,
9584    forwarding: Option<&ForwardingTable>,
9585    snapshot: &SharedSnapshot,
9586    module_id: &str,
9587) -> Result<(), SuperviseError> {
9588    let result = async {
9589        let registration = registry
9590            .get_module(module_id)
9591            .map_err(SuperviseError::Registry)?;
9592        match wait_for_registration_release(registry, module_id, REGISTRY_RELEASE_TIMEOUT).await {
9593            Ok(()) => return Ok(()),
9594            Err(SuperviseError::RegistrationStillActive { .. }) => {}
9595            Err(err) => return Err(err),
9596        }
9597        let pid = lock_snapshot(snapshot)?.reaped_pid;
9598        if let (Some(registration), Some(forwarding), Some(pid)) = (registration, forwarding, pid) {
9599            warn!(
9600                module_id,
9601                pid,
9602                connection_id = registration.connection_id.get(),
9603                "reaped module registration outlived release grace; closing dead connection"
9604            );
9605            forwarding.request_connection_close(
9606                registration.connection_id,
9607                CloseReason::new(
9608                    "supervised_process_reaped",
9609                    format!("module '{module_id}' pid {pid} exited"),
9610                ),
9611            );
9612            wait_for_slot_registration_release(
9613                registry,
9614                crate::registry::RegistrationSlot::Connection(registration.connection_id),
9615                REGISTRY_RELEASE_TIMEOUT,
9616            )
9617            .await?;
9618        }
9619        wait_for_registration_release(registry, module_id, Duration::ZERO).await
9620    }
9621    .await;
9622    if let Err(err) = &result {
9623        fail_snapshot(snapshot, Some(module_id), None);
9624        error!(module_id, error = %err, "registration release failed after child exit; module is failed and start can retry");
9625    }
9626    result
9627}
9628
9629/// Wait for the ACTIVE registration of `module_id` to go away, which is what a
9630/// plain stop or restart waits for before it spawns a replacement.
9631async fn wait_for_registration_release(
9632    registry: &Registry,
9633    module_id: &str,
9634    wait: Duration,
9635) -> Result<(), SuperviseError> {
9636    wait_for_slot_registration_release(
9637        registry,
9638        crate::registry::RegistrationSlot::Active(module_id),
9639        wait,
9640    )
9641    .await
9642}
9643
9644/// Wait for the registration in `slot` to go away.
9645///
9646/// Keyed on the slot rather than the bare module id because a successful swap
9647/// never empties the id's active slot (the promoted candidate is in it), so an
9648/// id-keyed wait for the incumbent's release would always time out. Draining a
9649/// swap's incumbent waits on `crate::registry::RegistrationSlot::Connection` with the
9650/// incumbent's connection instead.
9651async fn wait_for_slot_registration_release(
9652    registry: &Registry,
9653    slot: crate::registry::RegistrationSlot<'_>,
9654    wait: Duration,
9655) -> Result<(), SuperviseError> {
9656    let deadline = Instant::now() + wait;
9657    let mut release_events = registration_release_events().subscribe();
9658    let still_active = |registration: &crate::registry::ModuleRegistration| {
9659        SuperviseError::RegistrationStillActive {
9660            module_id: registration.manifest.module_id.clone(),
9661            waited: wait,
9662        }
9663    };
9664    loop {
9665        let _observed_generation = *release_events.borrow_and_update();
9666        let Some(registration) = registry
9667            .registration(slot)
9668            .map_err(SuperviseError::Registry)?
9669        else {
9670            return Ok(());
9671        };
9672
9673        let now = Instant::now();
9674        if now >= deadline {
9675            return Err(still_active(&registration));
9676        }
9677
9678        let remaining = deadline.saturating_duration_since(now);
9679        match timeout(remaining, release_events.changed()).await {
9680            Ok(Ok(())) | Ok(Err(_)) => {}
9681            Err(_) => return Err(still_active(&registration)),
9682        }
9683    }
9684}
9685
9686#[cfg(test)]
9687mod slot_registration_wait_tests {
9688    use super::*;
9689    use crate::registry::{ConnectionId, RegistrationSlot};
9690    use subc_protocol::manifest::ModuleManifest;
9691
9692    #[tokio::test]
9693    async fn enable_release_failure_is_failed_and_a_second_enable_retries() {
9694        let registry = Arc::new(Registry::default());
9695        let supervisor = Supervisor::new_for_test(Arc::clone(&registry), RestartPolicy::default());
9696        let runtime = supervisor.runtime_config();
9697        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::disabled()));
9698        let spec = ModuleSpec {
9699            module_id: "enable-stale-registration".to_string(),
9700            program: PathBuf::from("/missing/enable-retry-test"),
9701            args: Vec::new(),
9702            env: Vec::new(),
9703            reserved: false,
9704            reserved_prefixes: Vec::new(),
9705            protocol: ModuleProtocol::Subc,
9706            overlap: Default::default(),
9707        };
9708        let connection = ConnectionId::new(90);
9709        registry
9710            .register_with_control_ops(
9711                ModuleManifest::builder(&spec.module_id, "0.1.0").build(),
9712                1,
9713                connection,
9714                Vec::new(),
9715            )
9716            .unwrap();
9717        let mut child = None;
9718        let err = set_child_enabled(
9719            &spec,
9720            &runtime,
9721            &registry,
9722            &supervisor.process_liveness,
9723            &snapshot,
9724            &mut child,
9725            true,
9726        )
9727        .await
9728        .unwrap_err();
9729        assert!(matches!(
9730            err,
9731            SuperviseError::RegistrationStillActive { .. }
9732        ));
9733        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
9734        assert!(child.is_none());
9735        registry.deregister_connection(connection).unwrap();
9736        let err = set_child_enabled(
9737            &spec,
9738            &runtime,
9739            &registry,
9740            &supervisor.process_liveness,
9741            &snapshot,
9742            &mut child,
9743            true,
9744        )
9745        .await
9746        .unwrap_err();
9747        assert!(
9748            matches!(err, SuperviseError::Spawn { .. }),
9749            "second enable must attempt a spawn: {err}"
9750        );
9751        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
9752    }
9753
9754    const INCUMBENT: u64 = 1;
9755    const CANDIDATE: u64 = 2;
9756
9757    fn swapped_registry() -> Arc<Registry> {
9758        let registry = Arc::new(Registry::default());
9759        let manifest = ModuleManifest::builder("m", "0.1.0").build();
9760        registry
9761            .register_with_control_ops(
9762                manifest.clone(),
9763                1,
9764                ConnectionId::new(INCUMBENT),
9765                Vec::new(),
9766            )
9767            .unwrap();
9768        registry
9769            .register_candidate_with_control_ops(
9770                manifest,
9771                1,
9772                ConnectionId::new(CANDIDATE),
9773                Vec::new(),
9774            )
9775            .unwrap();
9776        registry
9777    }
9778
9779    /// After a promotion the id's active slot is held by the new process, so an
9780    /// id-keyed wait for the incumbent's release can never succeed; the
9781    /// connection-keyed wait completes as soon as the incumbent deregisters.
9782    #[tokio::test]
9783    async fn incumbent_release_is_awaited_by_connection_not_by_module_id() {
9784        let registry = swapped_registry();
9785        registry.promote_candidate("m").unwrap().unwrap();
9786
9787        assert!(matches!(
9788            wait_for_registration_release(&registry, "m", Duration::from_millis(50)).await,
9789            Err(SuperviseError::RegistrationStillActive { .. })
9790        ));
9791
9792        // Still held while the incumbent's connection has not deregistered.
9793        assert!(matches!(
9794            wait_for_slot_registration_release(
9795                &registry,
9796                RegistrationSlot::Connection(ConnectionId::new(INCUMBENT)),
9797                Duration::from_millis(50),
9798            )
9799            .await,
9800            Err(SuperviseError::RegistrationStillActive { .. })
9801        ));
9802
9803        let releaser = Arc::clone(&registry);
9804        let release = tokio::spawn(async move {
9805            sleep(Duration::from_millis(20)).await;
9806            releaser
9807                .deregister_connection(ConnectionId::new(INCUMBENT))
9808                .unwrap();
9809            notify_registration_release();
9810        });
9811        wait_for_slot_registration_release(
9812            &registry,
9813            RegistrationSlot::Connection(ConnectionId::new(INCUMBENT)),
9814            Duration::from_secs(5),
9815        )
9816        .await
9817        .expect("the incumbent's own registration is released");
9818        release.await.unwrap();
9819        assert!(registry.get_module("m").unwrap().is_some());
9820    }
9821
9822    /// The candidate slot is waited on separately from the active slot: the
9823    /// incumbent's registration neither holds up nor stands in for it.
9824    #[tokio::test]
9825    async fn candidate_slot_wait_ignores_the_incumbents_registration() {
9826        let registry = swapped_registry();
9827        assert!(matches!(
9828            wait_for_slot_registration_release(
9829                &registry,
9830                RegistrationSlot::Candidate("m"),
9831                Duration::from_millis(50),
9832            )
9833            .await,
9834            Err(SuperviseError::RegistrationStillActive { .. })
9835        ));
9836        registry
9837            .deregister_connection(ConnectionId::new(CANDIDATE))
9838            .unwrap();
9839        wait_for_slot_registration_release(
9840            &registry,
9841            RegistrationSlot::Candidate("m"),
9842            Duration::from_millis(50),
9843        )
9844        .await
9845        .expect("a candidate slot with no candidate is released");
9846        assert!(registry
9847            .registration(RegistrationSlot::Active("m"))
9848            .unwrap()
9849            .is_some());
9850    }
9851}
9852
9853fn classify_exit(status: &ExitStatus) -> ExitReport {
9854    ExitReport {
9855        kind: if status.success() {
9856            ExitKind::Clean
9857        } else {
9858            ExitKind::Crash
9859        },
9860        code: status.code(),
9861        signal: exit_signal(status),
9862        at_ms: unix_ms_now(),
9863    }
9864}
9865
9866/// The terminal record for a module whose `wait()` call itself errored (e.g. the
9867/// child was already reaped out-of-band). There is no `ExitStatus` to read a code
9868/// or signal from -- `None`/`None` is the honest shape, not a guess -- but the
9869/// disposition still must be `Failed` so the terminal ring is not silently missing
9870/// an entry, matching what `fail_snapshot` records for this same arm.
9871fn wait_error_exit_report() -> ExitReport {
9872    ExitReport {
9873        kind: ExitKind::Crash,
9874        code: None,
9875        signal: None,
9876        at_ms: unix_ms_now(),
9877    }
9878}
9879
9880#[cfg(unix)]
9881fn exit_signal(status: &ExitStatus) -> Option<i32> {
9882    use std::os::unix::process::ExitStatusExt;
9883
9884    status.signal()
9885}
9886
9887#[cfg(not(unix))]
9888fn exit_signal(_status: &ExitStatus) -> Option<i32> {
9889    None
9890}
9891
9892/// Give an operator-touched module its full crash budget back.
9893///
9894/// Named for the counter it used to zero; it now empties the in-window ring,
9895/// which is the same act. `lifetime_restarts` is untouched on purpose -- the
9896/// ledger of what happened survives every operator action.
9897fn reset_restart_count(snapshot: &SharedSnapshot, module_id: &str) -> Result<(), SuperviseError> {
9898    update_snapshot(snapshot, Some(module_id), |state| {
9899        state.clear_crash_restarts();
9900    })
9901}
9902
9903fn set_running(
9904    snapshot: &SharedSnapshot,
9905    child: &SupervisedChild,
9906    module_id: &str,
9907    spawn_events: &SpawnEventFeed,
9908) -> Result<(), SuperviseError> {
9909    let mut state = snapshot.lock().map_err(|_| SuperviseError::StatePoisoned {
9910        module_id: Some(module_id.to_string()),
9911    })?;
9912    state.spawn_generation = spawn_events.emit_spawned(module_id, child.pid, child.spawned_at_ms);
9913    if std::mem::take(&mut state.coalesced_restart_pending) {
9914        let generation = state.spawn_generation;
9915        info!(module_id, "restart already satisfied by generation {generation}; coalesced pending request completed");
9916    }
9917    state.drain_disposition_detail = None;
9918    state.spawn_failure = None;
9919    // Every caller of this is a plain spawn, which always uses the primary key;
9920    // a promoted swap candidate sets the flag itself after this returns.
9921    state.in_alternate_slot = false;
9922    state.configuration_updated_since_spawn = false;
9923    state.spawned_protocol = Some(child.protocol);
9924    state.state = ModuleState::Running;
9925    state.enabled = true;
9926    state.process_alive = true;
9927    state.pid = child.id();
9928    #[cfg(windows)]
9929    {
9930        state.pipe_consumed = child
9931            .nonce_delivery
9932            .as_ref()
9933            .map(|delivery| delivery.consumed());
9934    }
9935    #[cfg(target_os = "macos")]
9936    {
9937        state.report_ready = Some(Arc::clone(&child.report_ready));
9938    }
9939    state.spawned_at_ms = Some(child.spawned_at_ms);
9940    state.spawned_from = Some(child.spawned_from.clone());
9941    state.spawned_file_identity = child.spawned_file_identity.clone();
9942    state.process_start_time = child.process_start_time;
9943    Ok(())
9944}
9945
9946fn clear_current_process_facts(state: &mut SupervisorSnapshot) {
9947    state.process_alive = false;
9948    state.spawned_protocol = None;
9949    state.pid = None;
9950    #[cfg(windows)]
9951    {
9952        state.pipe_consumed = None;
9953    }
9954    #[cfg(target_os = "macos")]
9955    {
9956        state.report_ready = None;
9957    }
9958    state.spawned_at_ms = None;
9959    state.spawned_from = None;
9960    state.spawned_file_identity = None;
9961    state.process_start_time = None;
9962    state.deliberate_severance = None;
9963}
9964
9965#[cfg(test)]
9966fn record_deliberate_severance(
9967    snapshot: &SharedSnapshot,
9968    identity: ProcessIdentity,
9969) -> Result<(), SuperviseError> {
9970    update_snapshot(snapshot, None, |state| {
9971        state.deliberate_severance = Some(identity);
9972    })
9973}
9974
9975fn apply_deliberate_severance_marker(
9976    snapshot: &SharedSnapshot,
9977    exited_identity: Option<ProcessIdentity>,
9978    mut exit_report: ExitReport,
9979) -> ExitReport {
9980    let marker = lock_snapshot(snapshot)
9981        .ok()
9982        .and_then(|mut state| state.deliberate_severance.take());
9983    if marker.is_some() && marker == exited_identity {
9984        exit_report.kind = ExitKind::DeliberateSeverance;
9985    }
9986    exit_report
9987}
9988
9989fn classify_reaped_child_exit(
9990    snapshot: &SharedSnapshot,
9991    child: &SupervisedChild,
9992    status: &ExitStatus,
9993) -> ExitReport {
9994    let _ = update_snapshot(snapshot, None, |state| {
9995        state.reaped_pid = Some(child.pid);
9996        state.spawn_failure = child.spawn_failure.clone();
9997    });
9998    apply_deliberate_severance_marker(snapshot, child.process_identity(), classify_exit(status))
9999}
10000
10001fn fail_snapshot(
10002    snapshot: &SharedSnapshot,
10003    module_id: Option<&str>,
10004    last_exit: Option<ExitReport>,
10005) {
10006    if let Err(err) = update_snapshot(snapshot, module_id, |state| {
10007        state.state = ModuleState::Failed;
10008        clear_current_process_facts(state);
10009        if let Some(last_exit) = last_exit {
10010            state.last_exit = Some(last_exit);
10011        }
10012    }) {
10013        error!(error = %err, "failed to mark supervisor state failed");
10014    }
10015}
10016
10017fn update_snapshot(
10018    snapshot: &SharedSnapshot,
10019    module_id: Option<&str>,
10020    update: impl FnOnce(&mut SupervisorSnapshot),
10021) -> Result<(), SuperviseError> {
10022    let mut state = snapshot.lock().map_err(|_| SuperviseError::StatePoisoned {
10023        module_id: module_id.map(ToOwned::to_owned),
10024    })?;
10025    update(&mut state);
10026    Ok(())
10027}
10028
10029const SLOW_SNAPSHOT_LOCK_THRESHOLD: Duration = Duration::from_millis(250);
10030
10031fn lock_snapshot_for_control<'a>(
10032    snapshot: &'a SharedSnapshot,
10033    module_id: &str,
10034    caller: &'static str,
10035) -> Result<std::sync::MutexGuard<'a, SupervisorSnapshot>, SuperviseError> {
10036    let started_at = Instant::now();
10037    let guard = lock_snapshot(snapshot)?;
10038    let waited = started_at.elapsed();
10039    if waited >= SLOW_SNAPSHOT_LOCK_THRESHOLD {
10040        warn!(
10041            module_id = %module_id,
10042            waited_ms = waited.as_millis() as u64,
10043            caller = %caller,
10044            "slow snapshot lock"
10045        );
10046    }
10047    Ok(guard)
10048}
10049
10050fn lock_snapshot(
10051    snapshot: &SharedSnapshot,
10052) -> Result<std::sync::MutexGuard<'_, SupervisorSnapshot>, SuperviseError> {
10053    snapshot
10054        .lock()
10055        .map_err(|_| SuperviseError::StatePoisoned { module_id: None })
10056}
10057
10058#[cfg(test)]
10059mod terminal_history_tests {
10060    use std::{
10061        path::PathBuf,
10062        sync::Arc,
10063        time::{Duration, Instant},
10064    };
10065
10066    use tokio::{sync::mpsc, time::sleep};
10067
10068    use super::{
10069        apply_deliberate_severance_marker, daemon_will_restart, drain_child_to_state,
10070        drained_after_quiescence_wait, handle_reload_spawn_failure, health_restart_child,
10071        lock_snapshot, on_child_exit, record_deliberate_severance, record_wait_error_terminal,
10072        reset_restart_count, spawn_and_mark_running, update_snapshot, wait_error_exit_report,
10073        ExitKind, ExitReport, ModuleProtocol, ModuleSpec, ModuleState, NextAction, ProcessIdentity,
10074        RestartPolicy, SpawnEventKind, StopNotice, SuperviseError, SupervisedConfiguration,
10075        SupervisedModule, SupervisedModuleInner, Supervisor, SupervisorHandle,
10076        SupervisorHealthStatus, SupervisorSnapshot,
10077    };
10078    // The supervisor's clock, distinct from the `std::time::Instant` these tests
10079    // use for their own wall-clock deadlines: crash-restart instants must be on
10080    // the same clock the production code stamps them with, which is tokio's (and
10081    // is what `start_paused` tests can move).
10082    use super::Instant as ClockInstant;
10083    use crate::{
10084        registry::Registry,
10085        terminal_ring::{TerminalRing, TerminalRingConfig},
10086    };
10087    use std::sync::Mutex;
10088    use subc_control::TerminalDisposition;
10089
10090    /// See the twin in `control.rs` for why this derives the path from
10091    /// `current_exe()` and why the existence check is here: `--lib` alone does
10092    /// not build `[[bin]]` targets, and a bare spawn then fails with a raw
10093    /// `NotFound` that reads as a broken test rather than an unbuilt dependency.
10094    pub(super) fn fake_aft_stub_path() -> PathBuf {
10095        let mut path = std::env::current_exe().expect("current_exe available in tests");
10096        path.pop();
10097        path.pop();
10098        path.push(if cfg!(windows) {
10099            "fake-aft-stub.exe"
10100        } else {
10101            "fake-aft-stub"
10102        });
10103        assert!(
10104            path.exists(),
10105            "fake-aft-stub not built at {}: run `cargo test -p subc-core` (which builds \
10106             [[bin]] targets) rather than `cargo test -p subc-core --lib` (which does not)",
10107            path.display()
10108        );
10109        path
10110    }
10111
10112    #[test]
10113    fn reserved_never_spawned_refuses_every_hello() {
10114        // The canary hole: a reserved id whose module has never spawned had NO
10115        // gate entry and admitted anyone -- the reservation protected the nonce
10116        // holder, not the NAME. Now the entry is present with no legitimate
10117        // holder and refuses all comers.
10118        let supervisor = SupervisorHandle::default();
10119        supervisor.apply_identity_configuration(&ModuleSpec {
10120            module_id: "never-spawned".to_string(),
10121            program: PathBuf::from("/usr/bin/false"),
10122            args: Vec::new(),
10123            env: Vec::new(),
10124            reserved: true,
10125            reserved_prefixes: Vec::new(),
10126            protocol: ModuleProtocol::Subc,
10127            overlap: Default::default(),
10128        });
10129        assert!(
10130            supervisor
10131                .reserved_hello_rejection("never-spawned", Some("any-forged-nonce"))
10132                .is_some(),
10133            "forged nonce must refuse on a reserved never-spawned id"
10134        );
10135        assert!(
10136            supervisor
10137                .reserved_hello_rejection("never-spawned", None)
10138                .is_some(),
10139            "absent nonce must refuse on a reserved never-spawned id"
10140        );
10141        // And a real spawn nonce minted later admits exactly that nonce.
10142        supervisor.set_spawn_nonce("never-spawned", "minted".to_string());
10143        supervisor.apply_identity_configuration(&ModuleSpec {
10144            module_id: "never-spawned".to_string(),
10145            program: PathBuf::from("/usr/bin/false"),
10146            args: Vec::new(),
10147            env: Vec::new(),
10148            reserved: true,
10149            reserved_prefixes: Vec::new(),
10150            protocol: ModuleProtocol::Subc,
10151            overlap: Default::default(),
10152        });
10153        assert!(supervisor
10154            .reserved_hello_rejection("never-spawned", Some("minted"))
10155            .is_none());
10156        assert!(supervisor
10157            .reserved_hello_rejection("never-spawned", Some("forged"))
10158            .is_some());
10159    }
10160
10161    /// Put `count` crash restarts on a snapshot's ring as if they had all just
10162    /// happened, which is what "spent budget" looks like to every reader.
10163    fn seed_crash_restarts(state: &mut SupervisorSnapshot, count: u32) {
10164        let now = ClockInstant::now();
10165        for _ in 0..count {
10166            state.crash_restarts.push_back(now);
10167        }
10168    }
10169
10170    /// Age the oldest recorded restart out of `window`, standing in for the hours
10171    /// that would otherwise have to pass. Injecting the instant is the point: a
10172    /// test that slept a real window would take ten minutes and still prove less.
10173    fn age_oldest_crash_restart_out_of_window(state: &mut SupervisorSnapshot, window: Duration) {
10174        let aged = state
10175            .crash_restarts
10176            .front()
10177            .expect("a crash restart must be recorded before it can be aged")
10178            .checked_sub(window + Duration::from_secs(1))
10179            .expect("the test clock is far enough from its origin to age an instant");
10180        state.crash_restarts[0] = aged;
10181    }
10182
10183    fn snapshot_with_restarts(enabled: bool, count: u32) -> SupervisorSnapshot {
10184        let mut state = SupervisorSnapshot::new(ModuleState::Running, enabled);
10185        seed_crash_restarts(&mut state, count);
10186        state
10187    }
10188
10189    #[test]
10190    fn daemon_owned_recovery_predicate_uses_the_pre_increment_budget() {
10191        let policy = RestartPolicy::new(3, Duration::ZERO);
10192        let now = ClockInstant::now();
10193        assert!(daemon_will_restart(
10194            &mut snapshot_with_restarts(true, 2),
10195            &policy,
10196            now
10197        ));
10198        assert!(!daemon_will_restart(
10199            &mut snapshot_with_restarts(true, 3),
10200            &policy,
10201            now
10202        ));
10203        assert!(!daemon_will_restart(
10204            &mut snapshot_with_restarts(false, 0),
10205            &policy,
10206            now
10207        ));
10208    }
10209
10210    #[test]
10211    fn crash_restart_backoff_escalates_with_in_window_count() {
10212        let policy = RestartPolicy::new(4, Duration::from_millis(100))
10213            .with_max_backoff(Duration::from_secs(30));
10214        let now = ClockInstant::now();
10215        let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
10216        let schedules = (0..4)
10217            .map(|_| {
10218                state
10219                    .next_crash_restart(&policy, now)
10220                    .expect("the test policy allows four crash restarts")
10221            })
10222            .collect::<Vec<_>>();
10223
10224        assert_eq!(
10225            schedules
10226                .iter()
10227                .map(|schedule| schedule.restart_in_window)
10228                .collect::<Vec<_>>(),
10229            vec![0, 1, 2, 3]
10230        );
10231        assert_eq!(
10232            schedules
10233                .iter()
10234                .map(|schedule| schedule.delay)
10235                .collect::<Vec<_>>(),
10236            vec![
10237                Duration::from_millis(100),
10238                Duration::from_secs(1),
10239                Duration::from_secs(10),
10240                Duration::from_secs(30),
10241            ]
10242        );
10243    }
10244
10245    #[test]
10246    fn crash_restart_backoff_resets_after_ring_clear() {
10247        let policy = RestartPolicy::new(3, Duration::from_millis(100));
10248        let now = ClockInstant::now();
10249        let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
10250        assert_eq!(
10251            state.next_crash_restart(&policy, now).unwrap().delay,
10252            Duration::from_millis(100)
10253        );
10254        assert_eq!(
10255            state.next_crash_restart(&policy, now).unwrap().delay,
10256            Duration::from_secs(1)
10257        );
10258
10259        state.clear_crash_restarts();
10260        let schedule = state
10261            .next_crash_restart(&policy, now)
10262            .expect("a cleared ring must allow another restart");
10263        assert_eq!(schedule.restart_in_window, 0);
10264        assert_eq!(schedule.delay, Duration::from_millis(100));
10265    }
10266
10267    #[test]
10268    fn crash_restart_backoff_ignores_aged_restarts() {
10269        let policy = RestartPolicy::new(3, Duration::from_millis(100));
10270        let now = ClockInstant::now();
10271        let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
10272        state
10273            .next_crash_restart(&policy, now)
10274            .expect("the first restart is allowed");
10275        state
10276            .next_crash_restart(&policy, now)
10277            .expect("the second restart is allowed");
10278        state.crash_restarts[0] = now
10279            .checked_sub(policy.window + Duration::from_secs(1))
10280            .expect("the fake clock can age a restart past the window");
10281
10282        let schedule = state
10283            .next_crash_restart(&policy, now)
10284            .expect("an aged restart must release its slot");
10285        assert_eq!(schedule.restart_in_window, 1);
10286        assert_eq!(schedule.delay, Duration::from_secs(1));
10287        assert_eq!(state.crash_restarts.len(), 2);
10288    }
10289
10290    /// The budget is a rate: the same three spent restarts refuse a respawn
10291    /// while they are recent and allow one once they have aged past the window.
10292    /// Nothing about the module changed in between, which is the whole point.
10293    #[test]
10294    fn a_budget_spent_before_the_window_no_longer_refuses() {
10295        let policy = RestartPolicy::new(3, Duration::ZERO);
10296        let mut state = snapshot_with_restarts(true, 3);
10297        let now = ClockInstant::now();
10298        assert!(!daemon_will_restart(&mut state, &policy, now));
10299
10300        assert!(daemon_will_restart(
10301            &mut state,
10302            &policy,
10303            now + policy.window + Duration::from_secs(1)
10304        ));
10305        assert!(
10306            state.crash_restarts.is_empty(),
10307            "reading the budget must drop the instants that left the window"
10308        );
10309    }
10310
10311    fn module_with_recovery_snapshot(
10312        state: ModuleState,
10313        enabled: bool,
10314        restart_count: u32,
10315    ) -> SupervisedModule {
10316        let registry = Arc::new(Registry::default());
10317        let supervisor =
10318            Supervisor::new_for_test(Arc::clone(&registry), RestartPolicy::new(3, Duration::ZERO));
10319        let runtime = supervisor.runtime_config();
10320        let spec = ModuleSpec {
10321            module_id: "recovery-snapshot".to_string(),
10322            program: fake_aft_stub_path(),
10323            args: Vec::new(),
10324            env: Vec::new(),
10325            reserved: false,
10326            reserved_prefixes: Vec::new(),
10327            protocol: ModuleProtocol::Subc,
10328            overlap: Default::default(),
10329        };
10330        let mut snapshot = SupervisorSnapshot::new(state, enabled);
10331        seed_crash_restarts(&mut snapshot, restart_count);
10332        // These tests read synthetic snapshots. A real child and monitor would
10333        // race those reads by replacing the requested state during startup.
10334        let (commands, _rx) = mpsc::channel(4);
10335        SupervisedModule {
10336            inner: Arc::new(SupervisedModuleInner {
10337                module_id: spec.module_id.clone(),
10338                registry,
10339                snapshot: Arc::new(Mutex::new(snapshot)),
10340                configuration: Arc::new(Mutex::new(SupervisedConfiguration {
10341                    spec,
10342                    health: runtime.health,
10343                })),
10344                stderr_ring: runtime.stderr_ring,
10345                terminal_ring: runtime.terminal_ring,
10346                commands,
10347                monitor: Mutex::new(None),
10348                restart_policy: runtime.restart_policy,
10349                effective_drain_timeout: runtime.effective_drain_timeout,
10350                provenance_probe: supervisor.provenance_probe.clone(),
10351            }),
10352        }
10353    }
10354
10355    #[cfg(target_os = "linux")]
10356    #[tokio::test]
10357    async fn no_cgroup_placement_does_not_block_fake_aft_stub_spawn() {
10358        let supervisor =
10359            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
10360                .with_cgroup_placement(None);
10361        let result = supervisor.spawn(ModuleSpec {
10362            module_id: "no-cgroup-placement".to_string(),
10363            program: fake_aft_stub_path(),
10364            args: Vec::new(),
10365            env: Vec::new(),
10366            reserved: false,
10367            reserved_prefixes: Vec::new(),
10368            protocol: ModuleProtocol::Subc,
10369            overlap: Default::default(),
10370        });
10371
10372        assert!(
10373            result.is_ok(),
10374            "no delegation must not turn an otherwise valid spawn into a failure: {result:?}"
10375        );
10376    }
10377
10378    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10379    async fn undecided_snapshot_uses_shared_restart_predicate() {
10380        assert!(module_with_recovery_snapshot(ModuleState::Running, true, 2)
10381            .will_recover_after_connection_loss()
10382            .unwrap());
10383        assert!(
10384            !module_with_recovery_snapshot(ModuleState::Running, true, 3)
10385                .will_recover_after_connection_loss()
10386                .unwrap()
10387        );
10388    }
10389
10390    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10391    async fn restarting_snapshot_at_exhausted_budget_is_non_terminal() {
10392        assert!(
10393            module_with_recovery_snapshot(ModuleState::Restarting, true, 3)
10394                .will_recover_after_connection_loss()
10395                .unwrap()
10396        );
10397    }
10398
10399    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10400    async fn terminal_phase_snapshots_are_terminal_before_budget_exhaustion() {
10401        assert!(!module_with_recovery_snapshot(ModuleState::Failed, true, 0)
10402            .will_recover_after_connection_loss()
10403            .unwrap());
10404        assert!(
10405            !module_with_recovery_snapshot(ModuleState::Disabled, true, 0)
10406                .will_recover_after_connection_loss()
10407                .unwrap()
10408        );
10409    }
10410
10411    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10412    async fn warming_snapshot_is_limited_to_startup_phases() {
10413        for state in [
10414            ModuleState::Starting,
10415            ModuleState::Running,
10416            ModuleState::Restarting,
10417        ] {
10418            assert!(
10419                module_with_recovery_snapshot(state, true, 0)
10420                    .is_warming()
10421                    .unwrap(),
10422                "{state:?} should be warming"
10423            );
10424        }
10425        for state in [
10426            ModuleState::Unresponsive,
10427            ModuleState::Draining,
10428            ModuleState::Stopped,
10429            ModuleState::Failed,
10430            ModuleState::Disabled,
10431        ] {
10432            assert!(
10433                !module_with_recovery_snapshot(state, true, 0)
10434                    .is_warming()
10435                    .unwrap(),
10436                "{state:?} should not be warming"
10437            );
10438        }
10439    }
10440
10441    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10442    async fn terminal_history_survives_respawn_and_keeps_both_crashes_in_order() {
10443        let registry = Arc::new(Registry::default());
10444        let supervisor =
10445            Supervisor::new_for_test(Arc::clone(&registry), RestartPolicy::new(1, Duration::ZERO));
10446        let module = supervisor
10447            .spawn(ModuleSpec {
10448                module_id: "terminal-history".to_string(),
10449                program: fake_aft_stub_path(),
10450                args: Vec::new(),
10451                env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
10452                reserved: false,
10453                reserved_prefixes: Vec::new(),
10454                protocol: ModuleProtocol::Subc,
10455                overlap: Default::default(),
10456            })
10457            .unwrap();
10458
10459        let deadline = Instant::now() + Duration::from_secs(5);
10460        loop {
10461            let history = module.terminal_history();
10462            if history.entries.len() == 2 {
10463                assert_eq!(module.status().unwrap().state, ModuleState::Failed);
10464                assert_eq!(history.dropped, 0);
10465                assert_eq!(
10466                    history
10467                        .entries
10468                        .iter()
10469                        .map(|entry| entry.exit_code)
10470                        .collect::<Vec<_>>(),
10471                    vec![Some(23), Some(23)]
10472                );
10473                assert!(history.entries[0].at_ms <= history.entries[1].at_ms);
10474                return;
10475            }
10476            assert!(
10477                Instant::now() < deadline,
10478                "module did not retain two terminal exits: {history:?}"
10479            );
10480            sleep(Duration::from_millis(10)).await;
10481        }
10482    }
10483
10484    /// A disable issued while a crash respawn is still backing off must preempt
10485    /// that respawn: the operator's stop wins, the disable must not queue behind
10486    /// the backoff, and the module must never come back up afterwards.
10487    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10488    async fn disable_during_crash_backoff_cancels_pending_respawn() {
10489        let backoff = Duration::from_secs(2);
10490        let supervisor = Supervisor::new_for_test(
10491            Arc::new(Registry::default()),
10492            RestartPolicy::new(10, backoff),
10493        );
10494        let module = supervisor
10495            .spawn(ModuleSpec {
10496                module_id: "disable-during-backoff".to_string(),
10497                program: fake_aft_stub_path(),
10498                args: Vec::new(),
10499                env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
10500                reserved: false,
10501                reserved_prefixes: Vec::new(),
10502                protocol: ModuleProtocol::Subc,
10503                overlap: Default::default(),
10504            })
10505            .unwrap();
10506
10507        // Wait for the first crash to put the module into its backoff window.
10508        let deadline = Instant::now() + Duration::from_secs(5);
10509        loop {
10510            if module.status().unwrap().state == ModuleState::Restarting {
10511                break;
10512            }
10513            assert!(
10514                Instant::now() < deadline,
10515                "module never entered the crash backoff"
10516            );
10517            sleep(Duration::from_millis(10)).await;
10518        }
10519
10520        let started = Instant::now();
10521        module.set_enabled(false).await.unwrap();
10522        let waited = started.elapsed();
10523
10524        assert!(
10525            waited < backoff / 2,
10526            "disable waited {waited:?} behind the {backoff:?} crash backoff; the operator command must preempt the pending respawn"
10527        );
10528        assert_eq!(module.status().unwrap().state, ModuleState::Disabled);
10529
10530        // Outlast the backoff: the respawn it was counting down to must never run.
10531        sleep(backoff + Duration::from_millis(500)).await;
10532        let status = module.status().unwrap();
10533        assert_eq!(status.state, ModuleState::Disabled);
10534        assert_eq!(
10535            status.spawn_generation, 1,
10536            "module respawned after the operator disabled it"
10537        );
10538    }
10539
10540    /// A `protocol: "none"` child that stays parked and exits 0 on SIGTERM:
10541    /// the shape of nats-server, the program this rule exists for.
10542    #[cfg(unix)]
10543    fn protocol_none_sigterm_exits_clean_spec(
10544        module_id: &str,
10545        dir: &std::path::Path,
10546    ) -> (ModuleSpec, PathBuf, PathBuf) {
10547        let ready = dir.join("ready");
10548        let marker = dir.join("sigterm");
10549        let spec = ModuleSpec {
10550            module_id: module_id.to_string(),
10551            program: fake_aft_stub_path(),
10552            args: Vec::new(),
10553            env: vec![
10554                ("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string()),
10555                (
10556                    "FAKE_AFT_SIGTERM_MARKER_PATH".to_string(),
10557                    marker.display().to_string(),
10558                ),
10559                (
10560                    "FAKE_AFT_NEVER_CONNECT_READY_PATH".to_string(),
10561                    ready.display().to_string(),
10562                ),
10563            ],
10564            reserved: false,
10565            reserved_prefixes: Vec::new(),
10566            protocol: ModuleProtocol::None,
10567            overlap: Default::default(),
10568        };
10569        (spec, ready, marker)
10570    }
10571
10572    /// Wait for a file the child writes, so a signal is never sent before the
10573    /// child's SIGTERM handler is installed (the default disposition would
10574    /// kill it by signal and the exit would not be clean).
10575    #[cfg(unix)]
10576    async fn wait_for_file(path: &std::path::Path) {
10577        let deadline = Instant::now() + Duration::from_secs(10);
10578        while !path.exists() {
10579            assert!(
10580                Instant::now() < deadline,
10581                "{} never appeared",
10582                path.display()
10583            );
10584            sleep(Duration::from_millis(10)).await;
10585        }
10586    }
10587
10588    /// A protocol-none module that exits 0 because something OUTSIDE the
10589    /// supervisor sent it SIGTERM is respawned, and the terminal record carries
10590    /// the crash-path disposition rather than `stopped`.
10591    #[cfg(unix)]
10592    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10593    async fn protocol_none_unrequested_clean_exit_restarts_as_a_crash() {
10594        let dir = cortexkit_test_support::ScratchDir::new("none-unrequested-clean-exit");
10595        let (spec, ready, marker) =
10596            protocol_none_sigterm_exits_clean_spec("none-unrequested-clean-exit", dir.path());
10597        let supervisor = Supervisor::new_for_test(
10598            Arc::new(Registry::default()),
10599            RestartPolicy::new(3, Duration::ZERO),
10600        );
10601        let module = supervisor.spawn(spec).unwrap();
10602        wait_for_file(&ready).await;
10603        // The ready file proves the child installed its SIGTERM handler, not
10604        // that the supervisor has processed the privacy trampoline's exec
10605        // acknowledgement. On macOS status withholds the pid until then.
10606        let deadline = Instant::now() + Duration::from_secs(10);
10607        let first_pid = loop {
10608            let status = module.status().unwrap();
10609            if status.state == ModuleState::Running {
10610                if let Some(pid) = status.pid {
10611                    break pid;
10612                }
10613            }
10614            assert!(
10615                Instant::now() < deadline,
10616                "a running module must report its pid after exec confirmation: {status:?}"
10617            );
10618            sleep(Duration::from_millis(10)).await;
10619        };
10620
10621        rustix::process::kill_process(
10622            rustix::process::Pid::from_raw(i32::try_from(first_pid).unwrap()).unwrap(),
10623            rustix::process::Signal::TERM,
10624        )
10625        .unwrap();
10626
10627        let deadline = Instant::now() + Duration::from_secs(10);
10628        let respawned = loop {
10629            let status = module.status().unwrap();
10630            if status.state == ModuleState::Running
10631                && status.pid.is_some_and(|pid| pid != first_pid)
10632            {
10633                break status;
10634            }
10635            assert!(
10636                Instant::now() < deadline,
10637                "protocol-none module was not respawned after an unrequested clean exit: {status:?}"
10638            );
10639            sleep(Duration::from_millis(10)).await;
10640        };
10641        assert_eq!(respawned.spawn_generation, 2);
10642        assert!(
10643            marker.exists(),
10644            "the child must have exited through its SIGTERM handler (exit 0), or this proves nothing about clean exits"
10645        );
10646
10647        let history = module.terminal_history();
10648        assert_eq!(history.entries.len(), 1, "{history:?}");
10649        let entry = &history.entries[0];
10650        assert_eq!(entry.exit_code, Some(0));
10651        assert_eq!(entry.exit_kind, subc_control::TerminalExitKind::Clean);
10652        assert_eq!(entry.disposition, TerminalDisposition::Restarting);
10653
10654        module.stop().await.unwrap();
10655    }
10656
10657    /// Repeated unrequested clean exits of a protocol-none module spend the
10658    /// restart budget exactly as crashes do, and the module ends `failed` with
10659    /// the budget named.
10660    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10661    async fn protocol_none_repeated_clean_exits_exhaust_the_restart_budget() {
10662        let supervisor = Supervisor::new_for_test(
10663            Arc::new(Registry::default()),
10664            RestartPolicy::new(1, Duration::ZERO),
10665        );
10666        let module = supervisor
10667            .spawn(ModuleSpec {
10668                module_id: "none-clean-exit-budget".to_string(),
10669                program: fake_aft_stub_path(),
10670                args: Vec::new(),
10671                env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "0".to_string())],
10672                reserved: false,
10673                reserved_prefixes: Vec::new(),
10674                protocol: ModuleProtocol::None,
10675                overlap: Default::default(),
10676            })
10677            .unwrap();
10678
10679        // Failed follows the terminal write; this deadline only bounds a hang,
10680        // not an assumed duration for the two launches or their exit recording.
10681        let deadline = Instant::now() + Duration::from_secs(10);
10682        loop {
10683            let status = module.status().unwrap();
10684            if status.state == ModuleState::Failed {
10685                break;
10686            }
10687            assert!(
10688                Instant::now() < deadline,
10689                "module never exhausted its budget: {status:?} {:?}",
10690                module.terminal_history()
10691            );
10692            sleep(Duration::from_millis(10)).await;
10693        }
10694        let history = module.terminal_history();
10695        assert_eq!(
10696            history
10697                .entries
10698                .iter()
10699                .map(|entry| (entry.exit_code, entry.disposition.clone()))
10700                .collect::<Vec<_>>(),
10701            vec![
10702                (Some(0), TerminalDisposition::Restarting),
10703                (Some(0), TerminalDisposition::Failed),
10704            ]
10705        );
10706        let detail = history.entries[1]
10707            .disposition_detail
10708            .as_deref()
10709            .expect("a budget failure names the budget");
10710        assert!(detail.contains("max_restarts=1"), "{detail}");
10711        assert_eq!(module.status().unwrap().spawn_generation, 2);
10712    }
10713
10714    #[test]
10715    fn restart_budget_failure_is_published_after_its_terminal_record() {
10716        use std::io::{Read, Write};
10717        let supervisor = Supervisor::new_for_test(
10718            Arc::new(Registry::default()),
10719            RestartPolicy::new(0, Duration::ZERO),
10720        );
10721        let runtime = supervisor.runtime_config();
10722        let spec = ModuleSpec {
10723            protocol: ModuleProtocol::None,
10724            ..windowed_crash_spec("budget-publication-order")
10725        };
10726        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(
10727            ModuleState::Running,
10728            true,
10729        )));
10730        lock_snapshot(&snapshot).unwrap().process_alive = true;
10731        let reaped_snapshot = snapshot.clone();
10732        let mut barrier = terminal_record_barrier(&runtime.spawn_events);
10733        let history = runtime.terminal_ring.clone();
10734        let reap = std::thread::spawn(move || {
10735            tokio::runtime::Builder::new_current_thread()
10736                .enable_all()
10737                .build()
10738                .unwrap()
10739                .block_on(on_child_exit(
10740                    &spec,
10741                    runtime.restart_policy,
10742                    &supervisor.registry,
10743                    &reaped_snapshot,
10744                    &runtime.terminal_ring,
10745                    &runtime.spawn_events,
10746                    &runtime.child_roster,
10747                    ExitReport {
10748                        kind: ExitKind::Clean,
10749                        code: Some(0),
10750                        signal: None,
10751                        at_ms: 1,
10752                    },
10753                ))
10754        });
10755        let mut reached = [0];
10756        barrier.read_exact(&mut reached).unwrap();
10757        assert_eq!(&reached, b"R");
10758        let (before_state, before_alive) = {
10759            let state = lock_snapshot(&snapshot).unwrap();
10760            (state.state, state.process_alive)
10761        };
10762        let before_history = history.lock().unwrap().snapshot();
10763        barrier.write_all(b"X").unwrap();
10764        assert!(matches!(reap.join().unwrap(), NextAction::Stop { .. }));
10765        assert!(before_history.entries.is_empty());
10766        assert_ne!(
10767            before_state,
10768            ModuleState::Failed,
10769            "Failed was visible before its terminal record could be written"
10770        );
10771        assert!(before_alive);
10772        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
10773        let history = history.lock().unwrap().snapshot();
10774        assert_eq!(history.entries.len(), 1);
10775        assert_eq!(history.entries[0].exit_code, Some(0));
10776        assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
10777    }
10778
10779    fn terminal_record_barrier(events: &super::SpawnEventFeed) -> std::net::TcpStream {
10780        use std::net::{TcpListener, TcpStream};
10781        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
10782        let writer = TcpStream::connect(listener.local_addr().unwrap()).unwrap();
10783        let (barrier, _) = listener.accept().unwrap();
10784        for stream in [&writer, &barrier] {
10785            stream
10786                .set_read_timeout(Some(Duration::from_secs(10)))
10787                .unwrap();
10788            stream
10789                .set_write_timeout(Some(Duration::from_secs(10)))
10790                .unwrap();
10791        }
10792        *events.1.lock().unwrap() = Some(writer);
10793        barrier
10794    }
10795
10796    pub(super) fn before_terminal_record(events: &super::SpawnEventFeed) {
10797        use std::io::{Read, Write};
10798        let barrier = events.1.lock().unwrap().take();
10799        if let Some(mut barrier) = barrier {
10800            barrier.write_all(b"R").unwrap();
10801            let mut release = [0];
10802            barrier.read_exact(&mut release).unwrap();
10803            assert_eq!(&release, b"X");
10804        }
10805    }
10806
10807    #[test]
10808    fn health_restart_budget_failure_is_published_after_its_terminal_record() {
10809        use std::io::{Read, Write};
10810
10811        let supervisor = Supervisor::new_for_test(
10812            Arc::new(Registry::default()),
10813            RestartPolicy::new(0, Duration::ZERO),
10814        );
10815        let mut runtime = supervisor.runtime_config();
10816        let module = module_with_recovery_snapshot(ModuleState::Running, true, 0);
10817        let snapshot = module.inner.snapshot.clone();
10818        runtime.terminal_ring = module.inner.terminal_ring.clone();
10819        let spec = ModuleSpec {
10820            module_id: module.module_id().into(),
10821            program: fake_aft_stub_path(),
10822            protocol: ModuleProtocol::None,
10823            env: vec![("FAKE_AFT_EXIT_CODE".into(), "17".into())],
10824            ..windowed_crash_spec(module.module_id())
10825        };
10826        let mut barrier = terminal_record_barrier(&runtime.spawn_events);
10827        let reap = std::thread::spawn(move || {
10828            tokio::runtime::Builder::new_current_thread()
10829                .enable_all()
10830                .build()
10831                .unwrap()
10832                .block_on(async {
10833                    let mut child =
10834                        Some(spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap());
10835                    health_restart_child(
10836                        &spec,
10837                        &runtime,
10838                        &supervisor.registry,
10839                        &supervisor.process_liveness,
10840                        &snapshot,
10841                        &mut child,
10842                        SupervisorHealthStatus::Failing,
10843                        Some("health probe did not answer"),
10844                        1,
10845                    )
10846                    .await
10847                    .unwrap();
10848                    assert!(child.is_none());
10849                });
10850        });
10851        // The loopback barrier acknowledges entry into terminal recording by
10852        // health_restart_child's drain, then waits for the test's release byte.
10853        // Status and history are observed while that writer is held, not after
10854        // an elapsed-time guess about when the health action has completed.
10855        let mut reached = [0];
10856        barrier.read_exact(&mut reached).unwrap();
10857        assert_eq!(&reached, b"R");
10858        let before_status = module.status().unwrap();
10859        let before_history = module.terminal_history();
10860        barrier.write_all(b"X").unwrap();
10861        reap.join().unwrap();
10862        assert!(before_history.entries.is_empty());
10863        assert_ne!(
10864            before_status.state,
10865            ModuleState::Failed,
10866            "Failed was visible before its terminal record could be written"
10867        );
10868        assert!(
10869            before_status.process_alive,
10870            "process death was visible before its terminal record could be written"
10871        );
10872        let failed = module.status().unwrap();
10873        assert_eq!(failed.state, ModuleState::Failed);
10874        assert!(!failed.process_alive);
10875        let history = module.terminal_history();
10876        assert_eq!(history.entries.len(), 1);
10877        assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
10878        assert_eq!(
10879            history.entries[0].disposition_detail.as_deref(),
10880            Some("crash budget exhausted: max_restarts=0 within window_secs=600")
10881        );
10882    }
10883
10884    #[test]
10885    fn observed_exits_publish_process_death_after_terminal_history() {
10886        use std::io::{Read, Write};
10887        for (kind, enabled, shutdown, wait_error, expected_state) in [
10888            (ExitKind::Clean, true, false, false, ModuleState::Stopped),
10889            (ExitKind::Crash, true, false, false, ModuleState::Restarting),
10890            (ExitKind::Crash, false, false, false, ModuleState::Disabled),
10891            (
10892                ExitKind::DeliberateSeverance,
10893                true,
10894                false,
10895                false,
10896                ModuleState::Restarting,
10897            ),
10898            #[cfg(unix)]
10899            (ExitKind::Crash, true, true, false, ModuleState::Stopped),
10900            (ExitKind::Crash, true, false, true, ModuleState::Failed),
10901        ] {
10902            let supervisor = Supervisor::new_for_test(
10903                Arc::new(Registry::default()),
10904                RestartPolicy::new(1, Duration::ZERO),
10905            );
10906            let runtime = supervisor.runtime_config();
10907            #[cfg(unix)]
10908            if shutdown {
10909                runtime.child_roster.close();
10910            }
10911            #[cfg(not(unix))]
10912            let _ = shutdown;
10913            let spec = windowed_crash_spec("exit-publication-order");
10914            let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(
10915                ModuleState::Running,
10916                enabled,
10917            )));
10918            lock_snapshot(&snapshot).unwrap().process_alive = true;
10919            let reaped_snapshot = snapshot.clone();
10920            let history = runtime.terminal_ring.clone();
10921            let mut barrier = terminal_record_barrier(&runtime.spawn_events);
10922            let reap = std::thread::spawn(move || {
10923                if wait_error {
10924                    super::fail_after_wait_error(
10925                        &spec.module_id,
10926                        &reaped_snapshot,
10927                        &runtime.terminal_ring,
10928                        &runtime.spawn_events,
10929                        "wait failed".into(),
10930                    );
10931                } else {
10932                    tokio::runtime::Builder::new_current_thread()
10933                        .enable_all()
10934                        .build()
10935                        .unwrap()
10936                        .block_on(on_child_exit(
10937                            &spec,
10938                            runtime.restart_policy,
10939                            &supervisor.registry,
10940                            &reaped_snapshot,
10941                            &runtime.terminal_ring,
10942                            &runtime.spawn_events,
10943                            &runtime.child_roster,
10944                            ExitReport {
10945                                kind,
10946                                code: Some(0),
10947                                signal: None,
10948                                at_ms: 1,
10949                            },
10950                        ));
10951                }
10952            });
10953            let mut reached = [0];
10954            barrier.read_exact(&mut reached).unwrap();
10955            assert_eq!(&reached, b"R");
10956            let before_alive = lock_snapshot(&snapshot).unwrap().process_alive;
10957            let before_history = history.lock().unwrap().snapshot();
10958            barrier.write_all(b"X").unwrap();
10959            reap.join().unwrap();
10960            assert!(
10961                before_alive,
10962                "{kind:?}: death was published before recording"
10963            );
10964            assert!(before_history.entries.is_empty());
10965            assert_eq!(lock_snapshot(&snapshot).unwrap().state, expected_state);
10966            assert!(!lock_snapshot(&snapshot).unwrap().process_alive);
10967            assert_eq!(history.lock().unwrap().snapshot().entries.len(), 1);
10968        }
10969    }
10970
10971    #[tokio::test]
10972    async fn health_restart_budget_failure_remains_failed_when_journal_append_fails() {
10973        let dir = cortexkit_test_support::ScratchDir::new("health-budget-journal-failure");
10974        let path = dir.join("terminals.jsonl");
10975        std::fs::create_dir(&path).unwrap();
10976        let supervisor = Supervisor::new_for_test(
10977            Arc::new(Registry::default()),
10978            RestartPolicy::new(0, Duration::ZERO),
10979        )
10980        .with_terminal_journal(path, "health-budget-journal-failure".into());
10981        let runtime = supervisor.runtime_config();
10982        let spec = ModuleSpec {
10983            program: fake_aft_stub_path(),
10984            protocol: ModuleProtocol::None,
10985            env: vec![("FAKE_AFT_EXIT_CODE".into(), "17".into())],
10986            ..windowed_crash_spec("health-budget-journal-failure")
10987        };
10988        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
10989        let mut child = Some(spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap());
10990        health_restart_child(
10991            &spec,
10992            &runtime,
10993            &supervisor.registry,
10994            &supervisor.process_liveness,
10995            &snapshot,
10996            &mut child,
10997            SupervisorHealthStatus::Failing,
10998            None,
10999            1,
11000        )
11001        .await
11002        .unwrap();
11003        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
11004        assert!(!lock_snapshot(&snapshot).unwrap().process_alive);
11005        let history = runtime
11006            .terminal_ring
11007            .lock()
11008            .unwrap()
11009            .durable_history(&spec.module_id);
11010        assert!(history.journal_write_failures > 0);
11011        assert_eq!(history.entries.len(), 1);
11012        assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
11013    }
11014
11015    #[test]
11016    fn health_restart_budget_failure_remains_failed_when_exit_recording_panics() {
11017        let supervisor = Supervisor::new_for_test(
11018            Arc::new(Registry::default()),
11019            RestartPolicy::new(0, Duration::ZERO),
11020        );
11021        let runtime = supervisor.runtime_config();
11022        let spec = ModuleSpec {
11023            program: fake_aft_stub_path(),
11024            protocol: ModuleProtocol::None,
11025            env: vec![("FAKE_AFT_EXIT_CODE".into(), "17".into())],
11026            ..windowed_crash_spec("health-budget-recording-panic")
11027        };
11028        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11029        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
11030            tokio::runtime::Builder::new_current_thread()
11031                .enable_all()
11032                .build()
11033                .unwrap()
11034                .block_on(async {
11035                    let mut child =
11036                        Some(spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap());
11037                    runtime.spawn_events.0.lock().unwrap().seq = u64::MAX;
11038                    health_restart_child(
11039                        &spec,
11040                        &runtime,
11041                        &supervisor.registry,
11042                        &supervisor.process_liveness,
11043                        &snapshot,
11044                        &mut child,
11045                        SupervisorHealthStatus::Failing,
11046                        None,
11047                        1,
11048                    )
11049                    .await
11050                    .unwrap();
11051                });
11052        }));
11053        let panic = result.expect_err("recording must still unwind");
11054        assert_eq!(
11055            panic.downcast_ref::<String>().map(String::as_str),
11056            Some("spawn event sequence exhausted")
11057        );
11058        let state = lock_snapshot(&snapshot).unwrap();
11059        assert_eq!(state.state, ModuleState::Failed);
11060        assert!(!state.process_alive);
11061        assert!(state.last_exit.is_some());
11062    }
11063
11064    #[tokio::test]
11065    async fn restart_budget_failure_remains_failed_when_journal_append_fails() {
11066        let dir = cortexkit_test_support::ScratchDir::new("budget-journal-failure");
11067        let path = dir.join("terminals.jsonl");
11068        std::fs::create_dir(&path).unwrap();
11069        let supervisor = Supervisor::new_for_test(
11070            Arc::new(Registry::default()),
11071            RestartPolicy::new(0, Duration::ZERO),
11072        )
11073        .with_terminal_journal(path, "budget-journal-failure".into());
11074        let runtime = supervisor.runtime_config();
11075        let spec = windowed_crash_spec("budget-journal-failure");
11076        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11077        assert!(matches!(
11078            on_child_exit(
11079                &spec,
11080                runtime.restart_policy,
11081                &supervisor.registry,
11082                &snapshot,
11083                &runtime.terminal_ring,
11084                &runtime.spawn_events,
11085                &runtime.child_roster,
11086                crash_exit_report(1),
11087            )
11088            .await,
11089            NextAction::Stop { .. }
11090        ));
11091        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
11092        let history = runtime
11093            .terminal_ring
11094            .lock()
11095            .unwrap()
11096            .durable_history(&spec.module_id);
11097        assert!(history.journal_write_failures > 0);
11098        assert_eq!(history.entries.len(), 1);
11099        assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
11100    }
11101
11102    #[test]
11103    fn restart_budget_failure_remains_failed_when_exit_recording_panics() {
11104        let supervisor = Supervisor::new_for_test(
11105            Arc::new(Registry::default()),
11106            RestartPolicy::new(0, Duration::ZERO),
11107        );
11108        let runtime = supervisor.runtime_config();
11109        let spec = windowed_crash_spec("budget-recording-panic");
11110        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11111        runtime.spawn_events.emit_spawned(&spec.module_id, 1, 1);
11112        // Exhausting the event sequence makes emit_exited panic before the
11113        // terminal write, exercising publication on the recording unwind.
11114        runtime.spawn_events.0.lock().unwrap().seq = u64::MAX;
11115        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
11116            tokio::runtime::Builder::new_current_thread()
11117                .enable_all()
11118                .build()
11119                .unwrap()
11120                .block_on(on_child_exit(
11121                    &spec,
11122                    runtime.restart_policy,
11123                    &supervisor.registry,
11124                    &snapshot,
11125                    &runtime.terminal_ring,
11126                    &runtime.spawn_events,
11127                    &runtime.child_roster,
11128                    crash_exit_report(1),
11129                ))
11130        }));
11131        let panic = result.err().expect("recording must still unwind");
11132        assert_eq!(
11133            panic.downcast_ref::<String>().map(String::as_str),
11134            Some("spawn event sequence exhausted")
11135        );
11136        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
11137    }
11138
11139    /// A stop the supervisor itself requests still stops a protocol-none
11140    /// module, even though the child answers the SIGTERM with exit 0.
11141    #[cfg(unix)]
11142    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11143    async fn protocol_none_requested_stop_and_disable_do_not_respawn() {
11144        for disable in [false, true] {
11145            let label = if disable {
11146                "none-requested-disable"
11147            } else {
11148                "none-requested-stop"
11149            };
11150            let dir = cortexkit_test_support::ScratchDir::new(label);
11151            let (spec, ready, marker) = protocol_none_sigterm_exits_clean_spec(label, dir.path());
11152            let supervisor = Supervisor::new_for_test(
11153                Arc::new(Registry::default()),
11154                RestartPolicy::new(3, Duration::ZERO),
11155            );
11156            let module = supervisor.spawn(spec).unwrap();
11157            wait_for_file(&ready).await;
11158
11159            if disable {
11160                module.set_enabled(false).await.unwrap();
11161            } else {
11162                module.stop().await.unwrap();
11163            }
11164            assert!(
11165                marker.exists(),
11166                "{label}: the child must have left through its SIGTERM handler with exit 0"
11167            );
11168
11169            // Long enough for a zero-backoff respawn to have happened if the
11170            // exit had been treated as a crash.
11171            sleep(Duration::from_millis(500)).await;
11172            let status = module.status().unwrap();
11173            let expected = if disable {
11174                ModuleState::Disabled
11175            } else {
11176                ModuleState::Stopped
11177            };
11178            assert_eq!(status.state, expected, "{label}");
11179            assert_eq!(
11180                status.spawn_generation, 1,
11181                "{label}: respawned after a requested stop"
11182            );
11183            let history = module.terminal_history();
11184            assert_eq!(history.entries.len(), 1, "{label}: {history:?}");
11185            assert_eq!(history.entries[0].exit_code, Some(0), "{label}");
11186            assert_ne!(
11187                history.entries[0].disposition,
11188                TerminalDisposition::Restarting,
11189                "{label}"
11190            );
11191        }
11192    }
11193
11194    /// A subc-wire module that exits 0 on its own is still a stop: the
11195    /// protocol-none rule must not reach it.
11196    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11197    async fn subc_wire_clean_exit_is_still_a_stop() {
11198        let supervisor = Supervisor::new_for_test(
11199            Arc::new(Registry::default()),
11200            RestartPolicy::new(3, Duration::ZERO),
11201        );
11202        let module = supervisor
11203            .spawn(ModuleSpec {
11204                module_id: "wire-clean-exit".to_string(),
11205                program: fake_aft_stub_path(),
11206                args: Vec::new(),
11207                env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "0".to_string())],
11208                reserved: false,
11209                reserved_prefixes: Vec::new(),
11210                protocol: ModuleProtocol::Subc,
11211                overlap: Default::default(),
11212            })
11213            .unwrap();
11214
11215        let deadline = Instant::now() + Duration::from_secs(10);
11216        while module.terminal_history().entries.is_empty() {
11217            assert!(Instant::now() < deadline, "module never exited");
11218            sleep(Duration::from_millis(10)).await;
11219        }
11220        // Long enough for a zero-backoff respawn to have happened.
11221        sleep(Duration::from_millis(500)).await;
11222        let status = module.status().unwrap();
11223        assert_eq!(status.state, ModuleState::Stopped);
11224        assert_eq!(status.spawn_generation, 1);
11225        let history = module.terminal_history();
11226        assert_eq!(history.entries.len(), 1, "{history:?}");
11227        assert_eq!(history.entries[0].exit_code, Some(0));
11228        assert_eq!(history.entries[0].disposition, TerminalDisposition::Stopped);
11229    }
11230
11231    #[cfg(unix)]
11232    #[tokio::test]
11233    async fn reload_exit_keeps_roster_until_terminal_is_recorded_during_shutdown() {
11234        let dir = cortexkit_test_support::ScratchDir::new("reload-roster-terminal-order");
11235        let record = dir.join("live-children.json");
11236        let supervisor = Supervisor::new_for_test(
11237            Arc::new(Registry::default()),
11238            RestartPolicy::new(0, Duration::ZERO),
11239        );
11240        let mut runtime = supervisor.runtime_config();
11241        runtime.child_roster.record_to(record.clone());
11242        let gate = Arc::new(super::ReloadExitRecordGate::default());
11243        runtime.test_reload_exit_record_gate = Some(Arc::clone(&gate));
11244        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11245        let spec = ModuleSpec {
11246            module_id: "reload-exit-roster".into(),
11247            program: fake_aft_stub_path(),
11248            args: Vec::new(),
11249            env: vec![("FAKE_AFT_EXIT_CODE".into(), "23".into())],
11250            reserved: false,
11251            reserved_prefixes: Vec::new(),
11252            protocol: ModuleProtocol::Subc,
11253            overlap: Default::default(),
11254        };
11255        let mut child = None;
11256        let reload = super::finish_reload_child(
11257            &spec,
11258            &runtime,
11259            &supervisor.registry,
11260            &supervisor.process_liveness,
11261            &snapshot,
11262            &mut child,
11263        );
11264        tokio::pin!(reload);
11265        tokio::select! {
11266            result = &mut reload => panic!("reload missed the before-record gate: {result:?}"),
11267            _ = gate.reached.notified() => {}
11268        }
11269        assert!(runtime
11270            .terminal_ring
11271            .lock()
11272            .unwrap()
11273            .snapshot()
11274            .entries
11275            .is_empty());
11276        assert_eq!(
11277            crate::live_children::read_record(&record).unwrap().len(),
11278            1,
11279            "shutdown must still wait for the reaped child until its terminal record exists"
11280        );
11281        runtime.child_roster.close();
11282        gate.resume.notify_one();
11283        assert!(reload.await.is_err());
11284        assert!(crate::live_children::read_record(&record)
11285            .unwrap()
11286            .is_empty());
11287        let history = runtime.terminal_ring.lock().unwrap().snapshot();
11288        assert_eq!(history.entries.len(), 1);
11289        assert_eq!(
11290            history.entries[0].disposition,
11291            TerminalDisposition::DaemonShutdown
11292        );
11293    }
11294
11295    /// Each restart-producing arm has its own state transition. Keeping their
11296    /// lifetime count assertions adjacent prevents a later new arm from silently
11297    /// spending budget without recording the historical restart.
11298    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11299    async fn every_restart_increment_path_advances_lifetime_count() {
11300        let supervisor = Supervisor::new_for_test(
11301            Arc::new(Registry::default()),
11302            RestartPolicy::new(1, Duration::ZERO),
11303        );
11304        let runtime = supervisor.runtime_config();
11305        let spec = ModuleSpec {
11306            module_id: "lifetime-increment-path".to_string(),
11307            program: PathBuf::from("/unused/lifetime-increment-path"),
11308            args: Vec::new(),
11309            env: Vec::new(),
11310            reserved: false,
11311            reserved_prefixes: Vec::new(),
11312            protocol: ModuleProtocol::Subc,
11313            overlap: Default::default(),
11314        };
11315
11316        let crash_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11317        assert!(matches!(
11318            on_child_exit(
11319                &spec,
11320                runtime.restart_policy,
11321                &supervisor.registry,
11322                &crash_snapshot,
11323                &runtime.terminal_ring,
11324                &runtime.spawn_events,
11325                &runtime.child_roster,
11326                ExitReport {
11327                    kind: ExitKind::Crash,
11328                    code: Some(1),
11329                    signal: None,
11330                    at_ms: 1,
11331                },
11332            )
11333            .await,
11334            NextAction::Restart { schedule: _ }
11335        ));
11336        let (crash_restarts, crash_lifetime) = {
11337            let state = lock_snapshot(&crash_snapshot).unwrap();
11338            (state.crash_restarts.len(), state.lifetime_restarts)
11339        };
11340        assert_eq!(crash_restarts, 1);
11341        assert_eq!(crash_lifetime, 1);
11342
11343        let health_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11344        let mut health_child = None;
11345        assert!(matches!(
11346            health_restart_child(
11347                &spec,
11348                &runtime,
11349                &supervisor.registry,
11350                &supervisor.process_liveness,
11351                &health_snapshot,
11352                &mut health_child,
11353                SupervisorHealthStatus::Failing,
11354                None,
11355                2,
11356            )
11357            .await,
11358            Ok(())
11359        ));
11360        assert!(health_child.is_none());
11361        assert!(lock_snapshot(&health_snapshot).unwrap().respawn_pending);
11362        assert!(runtime.scheduled_respawn.lock().unwrap().take().is_some());
11363        let (health_restarts, health_lifetime) = {
11364            let state = lock_snapshot(&health_snapshot).unwrap();
11365            (state.crash_restarts.len(), state.lifetime_restarts)
11366        };
11367        assert_eq!(health_restarts, 1);
11368        assert_eq!(health_lifetime, 1);
11369
11370        let reload_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11371        let mut reload_child = None;
11372        assert!(matches!(
11373            handle_reload_spawn_failure(
11374                &spec,
11375                &runtime,
11376                &supervisor.process_liveness,
11377                &reload_snapshot,
11378                &mut reload_child,
11379                "forced reload spawn failure".to_string(),
11380            )
11381            .await,
11382            Err(SuperviseError::ReloadFailed { .. })
11383        ));
11384        let (reload_restarts, reload_lifetime) = {
11385            let state = lock_snapshot(&reload_snapshot).unwrap();
11386            (state.crash_restarts.len(), state.lifetime_restarts)
11387        };
11388        assert_eq!(reload_restarts, 1);
11389        assert_eq!(reload_lifetime, 1);
11390    }
11391
11392    #[tokio::test]
11393    async fn deliberately_severed_live_child_records_lifetime_without_spending_restart_budget() {
11394        let supervisor = Supervisor::new_for_test(
11395            Arc::new(Registry::default()),
11396            RestartPolicy::new(3, Duration::ZERO),
11397        );
11398        let runtime = supervisor.runtime_config();
11399        let spec = ModuleSpec {
11400            module_id: "deliberately-severed".to_string(),
11401            program: PathBuf::from("/unused/deliberately-severed"),
11402            args: Vec::new(),
11403            env: Vec::new(),
11404            reserved: false,
11405            reserved_prefixes: Vec::new(),
11406            protocol: ModuleProtocol::Subc,
11407            overlap: Default::default(),
11408        };
11409        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11410        let process = ProcessIdentity {
11411            pid: 41,
11412            start_time: 101,
11413        };
11414        record_deliberate_severance(&snapshot, process).unwrap();
11415        let exit_report = apply_deliberate_severance_marker(
11416            &snapshot,
11417            Some(process),
11418            ExitReport {
11419                kind: ExitKind::Crash,
11420                code: Some(1),
11421                signal: None,
11422                at_ms: 1,
11423            },
11424        );
11425        assert_eq!(exit_report.kind, ExitKind::DeliberateSeverance);
11426
11427        assert!(matches!(
11428            on_child_exit(
11429                &spec,
11430                runtime.restart_policy,
11431                &supervisor.registry,
11432                &snapshot,
11433                &runtime.terminal_ring,
11434                &runtime.spawn_events,
11435                &runtime.child_roster,
11436                exit_report,
11437            )
11438            .await,
11439            NextAction::Restart { schedule: _ }
11440        ));
11441        let state = lock_snapshot(&snapshot).unwrap();
11442        assert_eq!(state.lifetime_restarts, 1);
11443        assert_eq!(state.crash_restarts.len(), 0);
11444    }
11445
11446    #[tokio::test]
11447    async fn genuine_crash_spends_restart_budget_and_records_lifetime() {
11448        let supervisor = Supervisor::new_for_test(
11449            Arc::new(Registry::default()),
11450            RestartPolicy::new(3, Duration::ZERO),
11451        );
11452        let runtime = supervisor.runtime_config();
11453        let spec = ModuleSpec {
11454            module_id: "genuine-crash".to_string(),
11455            program: PathBuf::from("/unused/genuine-crash"),
11456            args: Vec::new(),
11457            env: Vec::new(),
11458            reserved: false,
11459            reserved_prefixes: Vec::new(),
11460            protocol: ModuleProtocol::Subc,
11461            overlap: Default::default(),
11462        };
11463        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11464
11465        assert!(matches!(
11466            on_child_exit(
11467                &spec,
11468                runtime.restart_policy,
11469                &supervisor.registry,
11470                &snapshot,
11471                &runtime.terminal_ring,
11472                &runtime.spawn_events,
11473                &runtime.child_roster,
11474                ExitReport {
11475                    kind: ExitKind::Crash,
11476                    code: Some(1),
11477                    signal: None,
11478                    at_ms: 1,
11479                },
11480            )
11481            .await,
11482            NextAction::Restart { schedule: _ }
11483        ));
11484        let state = lock_snapshot(&snapshot).unwrap();
11485        assert_eq!(state.lifetime_restarts, 1);
11486        assert_eq!(state.crash_restarts.len(), 1);
11487    }
11488
11489    fn crash_exit_report(at_ms: u64) -> ExitReport {
11490        ExitReport {
11491            kind: ExitKind::Crash,
11492            code: Some(1),
11493            signal: None,
11494            at_ms,
11495        }
11496    }
11497
11498    fn windowed_crash_spec(module_id: &str) -> ModuleSpec {
11499        ModuleSpec {
11500            module_id: module_id.to_string(),
11501            program: PathBuf::from("/unused").join(module_id),
11502            args: Vec::new(),
11503            env: Vec::new(),
11504            reserved: false,
11505            reserved_prefixes: Vec::new(),
11506            protocol: ModuleProtocol::Subc,
11507            overlap: Default::default(),
11508        }
11509    }
11510
11511    /// A real crash loop still stops. Three crashes with nothing aging out spend
11512    /// a budget of two and the third respawn is refused, and both surfaces an
11513    /// operator has -- the log line and the retained terminal record -- name the
11514    /// window rather than only the cap, because `max_restarts=2` alone is what
11515    /// this budget used to mean.
11516    #[tokio::test]
11517    async fn three_crashes_inside_the_window_stop_the_module_and_name_the_window() {
11518        let (logs, _guard) = crate::router::test_log::log_capture(tracing::Level::ERROR);
11519        let supervisor = Supervisor::new_for_test(
11520            Arc::new(Registry::default()),
11521            RestartPolicy::new(2, Duration::ZERO),
11522        );
11523        let runtime = supervisor.runtime_config();
11524        let spec = windowed_crash_spec("crash-loop-in-window");
11525        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11526
11527        for attempt in 1..=2 {
11528            assert!(
11529                matches!(
11530                    on_child_exit(
11531                        &spec,
11532                        runtime.restart_policy,
11533                        &supervisor.registry,
11534                        &snapshot,
11535                        &runtime.terminal_ring,
11536                        &runtime.spawn_events,
11537                        &runtime.child_roster,
11538                        crash_exit_report(attempt),
11539                    )
11540                    .await,
11541                    NextAction::Restart { schedule: _ }
11542                ),
11543                "crash {attempt} is inside the budget and must respawn"
11544            );
11545        }
11546
11547        assert!(matches!(
11548            on_child_exit(
11549                &spec,
11550                runtime.restart_policy,
11551                &supervisor.registry,
11552                &snapshot,
11553                &runtime.terminal_ring,
11554                &runtime.spawn_events,
11555                &runtime.child_roster,
11556                crash_exit_report(3),
11557            )
11558            .await,
11559            NextAction::Stop { .. }
11560        ));
11561
11562        {
11563            let state = lock_snapshot(&snapshot).unwrap();
11564            assert_eq!(state.state, ModuleState::Failed);
11565            assert_eq!(state.crash_restarts.len(), 2);
11566            assert_eq!(state.lifetime_restarts, 2);
11567        }
11568
11569        let history = runtime
11570            .terminal_ring
11571            .lock()
11572            .expect("terminal ring is not poisoned")
11573            .snapshot();
11574        let last = history
11575            .entries
11576            .last()
11577            .expect("the refused crash is retained");
11578        assert_eq!(last.disposition, TerminalDisposition::Failed);
11579        assert_eq!(
11580            last.disposition_detail.as_deref(),
11581            Some("crash budget exhausted: max_restarts=2 within window_secs=600")
11582        );
11583
11584        let captured = crate::router::test_log::captured_logs(&logs);
11585        assert!(
11586            captured.contains("crash budget exhausted: max_restarts=2 within window_secs=600"),
11587            "the stop must be logged with its window: {captured}"
11588        );
11589    }
11590
11591    /// The rate, stated as a test: three crashes where the first has aged past
11592    /// the window are two crashes as far as the budget is concerned, so the
11593    /// third respawn is allowed and the ring holds only the two recent ones.
11594    ///
11595    /// This is the case a lifetime counter got wrong -- and the case the daemon
11596    /// now hits routinely, since a module exits non-zero every time its
11597    /// connection to the daemon drops.
11598    #[tokio::test]
11599    async fn a_crash_older_than_the_window_frees_its_slot_for_a_later_crash() {
11600        let supervisor = Supervisor::new_for_test(
11601            Arc::new(Registry::default()),
11602            RestartPolicy::new(2, Duration::ZERO),
11603        );
11604        let runtime = supervisor.runtime_config();
11605        let spec = windowed_crash_spec("crash-across-windows");
11606        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11607
11608        for attempt in 1..=2 {
11609            assert!(matches!(
11610                on_child_exit(
11611                    &spec,
11612                    runtime.restart_policy,
11613                    &supervisor.registry,
11614                    &snapshot,
11615                    &runtime.terminal_ring,
11616                    &runtime.spawn_events,
11617                    &runtime.child_roster,
11618                    crash_exit_report(attempt),
11619                )
11620                .await,
11621                NextAction::Restart { schedule: _ }
11622            ));
11623        }
11624
11625        // The oldest crash moves out of the window; nothing else about the
11626        // module changes.
11627        update_snapshot(&snapshot, Some(&spec.module_id), |state| {
11628            age_oldest_crash_restart_out_of_window(state, runtime.restart_policy.window);
11629        })
11630        .unwrap();
11631
11632        assert!(
11633            matches!(
11634                on_child_exit(
11635                    &spec,
11636                    runtime.restart_policy,
11637                    &supervisor.registry,
11638                    &snapshot,
11639                    &runtime.terminal_ring,
11640                    &runtime.spawn_events,
11641                    &runtime.child_roster,
11642                    crash_exit_report(3),
11643                )
11644                .await,
11645                NextAction::Restart { schedule: _ }
11646            ),
11647            "a crash older than the window must not hold a budget slot"
11648        );
11649
11650        let state = lock_snapshot(&snapshot).unwrap();
11651        assert_eq!(state.state, ModuleState::Restarting);
11652        assert_eq!(
11653            state.crash_restarts.len(),
11654            2,
11655            "the aged instant is dropped and the new one takes its place"
11656        );
11657        assert_eq!(
11658            state.lifetime_restarts, 3,
11659            "the ledger counts every restart, including the ones the window forgot"
11660        );
11661    }
11662
11663    /// An operator restart hands the budget back whole, and the ledger keeps
11664    /// counting. Those are different questions -- "how close is this module to
11665    /// being stopped" and "how many times has it been replaced" -- and the
11666    /// operator action answers only the first.
11667    #[tokio::test]
11668    async fn an_operator_restart_clears_the_ring_and_leaves_the_ledger_alone() {
11669        let supervisor = Supervisor::new_for_test(
11670            Arc::new(Registry::default()),
11671            RestartPolicy::new(2, Duration::ZERO),
11672        );
11673        let runtime = supervisor.runtime_config();
11674        let spec = windowed_crash_spec("operator-cleared-budget");
11675        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11676
11677        for attempt in 1..=2 {
11678            assert!(matches!(
11679                on_child_exit(
11680                    &spec,
11681                    runtime.restart_policy,
11682                    &supervisor.registry,
11683                    &snapshot,
11684                    &runtime.terminal_ring,
11685                    &runtime.spawn_events,
11686                    &runtime.child_roster,
11687                    crash_exit_report(attempt),
11688                )
11689                .await,
11690                NextAction::Restart { schedule: _ }
11691            ));
11692        }
11693
11694        reset_restart_count(&snapshot, &spec.module_id).unwrap();
11695        {
11696            let state = lock_snapshot(&snapshot).unwrap();
11697            assert!(
11698                state.crash_restarts.is_empty(),
11699                "an operator restart returns the full budget"
11700            );
11701            assert_eq!(
11702                state.lifetime_restarts, 2,
11703                "clearing the budget must not unmake the crashes"
11704            );
11705        }
11706
11707        assert!(
11708            matches!(
11709                on_child_exit(
11710                    &spec,
11711                    runtime.restart_policy,
11712                    &supervisor.registry,
11713                    &snapshot,
11714                    &runtime.terminal_ring,
11715                    &runtime.spawn_events,
11716                    &runtime.child_roster,
11717                    crash_exit_report(3),
11718                )
11719                .await,
11720                NextAction::Restart { schedule: _ }
11721            ),
11722            "the cleared budget must be spendable again"
11723        );
11724        let state = lock_snapshot(&snapshot).unwrap();
11725        assert_eq!(state.crash_restarts.len(), 1);
11726        assert_eq!(state.lifetime_restarts, 3);
11727    }
11728
11729    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11730    async fn severance_marker_for_a_dead_child_does_not_label_its_successor() {
11731        let severed = ProcessIdentity {
11732            pid: 41,
11733            start_time: 101,
11734        };
11735        let successor = ProcessIdentity {
11736            pid: 41,
11737            start_time: 202,
11738        };
11739        let module = module_with_recovery_snapshot(ModuleState::Running, true, 0);
11740        update_snapshot(&module.inner.snapshot, Some("recovery-snapshot"), |state| {
11741            state.pid = Some(successor.pid);
11742            state.process_start_time = Some(successor.start_time);
11743        })
11744        .unwrap();
11745        assert!(!module.record_deliberate_severance(severed).unwrap());
11746
11747        let exit_report = apply_deliberate_severance_marker(
11748            &module.inner.snapshot,
11749            Some(successor),
11750            ExitReport {
11751                kind: ExitKind::Crash,
11752                code: Some(1),
11753                signal: None,
11754                at_ms: 1,
11755            },
11756        );
11757
11758        assert_eq!(exit_report.kind, ExitKind::Crash);
11759    }
11760
11761    #[tokio::test]
11762    async fn drain_reap_marks_deliberate_severance_and_records_lifetime_without_budget() {
11763        let registry = Registry::default();
11764        let supervisor = Supervisor::new_for_test(
11765            Arc::new(Registry::default()),
11766            RestartPolicy::new(3, Duration::ZERO),
11767        );
11768        let runtime = supervisor.runtime_config();
11769        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11770        let spec = ModuleSpec {
11771            module_id: "drain-deliberate-severance".to_string(),
11772            program: fake_aft_stub_path(),
11773            args: Vec::new(),
11774            env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
11775            reserved: false,
11776            reserved_prefixes: Vec::new(),
11777            protocol: ModuleProtocol::Subc,
11778            overlap: Default::default(),
11779        };
11780        let mut child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
11781        let process = ProcessIdentity {
11782            pid: 41,
11783            start_time: 101,
11784        };
11785        child.process_identity = Some(process);
11786        update_snapshot(&snapshot, Some(&spec.module_id), |state| {
11787            state.pid = Some(process.pid);
11788            state.process_start_time = Some(process.start_time);
11789        })
11790        .unwrap();
11791        record_deliberate_severance(&snapshot, process).unwrap();
11792
11793        drain_child_to_state(
11794            &spec.module_id,
11795            spec.protocol,
11796            // The child exits on its own; no signal may change the exit this
11797            // test classifies.
11798            StopNotice::SentOverConnection,
11799            &registry,
11800            None,
11801            &snapshot,
11802            &runtime.terminal_ring,
11803            &runtime.spawn_events,
11804            child,
11805            Duration::from_secs(1),
11806            ModuleState::Stopped,
11807            Some(false),
11808        )
11809        .await
11810        .unwrap();
11811
11812        let state = lock_snapshot(&snapshot).unwrap();
11813        assert_eq!(
11814            state.last_exit.as_ref().map(|exit| exit.kind),
11815            Some(ExitKind::DeliberateSeverance)
11816        );
11817        assert_eq!(state.lifetime_restarts, 1);
11818        assert_eq!(state.crash_restarts.len(), 0);
11819        drop(state);
11820        let history = runtime.terminal_ring.lock().unwrap().snapshot();
11821        assert_eq!(
11822            history.entries[0].exit_kind,
11823            subc_control::TerminalExitKind::DeliberateSeverance
11824        );
11825    }
11826
11827    #[tokio::test]
11828    async fn ordinary_drain_reap_does_not_record_a_lifetime_restart() {
11829        let registry = Registry::default();
11830        let supervisor = Supervisor::new_for_test(
11831            Arc::new(Registry::default()),
11832            RestartPolicy::new(3, Duration::ZERO),
11833        );
11834        let runtime = supervisor.runtime_config();
11835        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11836        let spec = ModuleSpec {
11837            module_id: "ordinary-drain".to_string(),
11838            program: fake_aft_stub_path(),
11839            args: Vec::new(),
11840            env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
11841            reserved: false,
11842            reserved_prefixes: Vec::new(),
11843            protocol: ModuleProtocol::Subc,
11844            overlap: Default::default(),
11845        };
11846        let child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
11847
11848        drain_child_to_state(
11849            &spec.module_id,
11850            spec.protocol,
11851            // The child exits on its own; no signal may change the exit this
11852            // test classifies.
11853            StopNotice::SentOverConnection,
11854            &registry,
11855            None,
11856            &snapshot,
11857            &runtime.terminal_ring,
11858            &runtime.spawn_events,
11859            child,
11860            Duration::from_secs(1),
11861            ModuleState::Stopped,
11862            Some(false),
11863        )
11864        .await
11865        .unwrap();
11866
11867        let state = lock_snapshot(&snapshot).unwrap();
11868        assert_eq!(
11869            state.last_exit.as_ref().map(|exit| exit.kind),
11870            Some(ExitKind::Crash)
11871        );
11872        assert_eq!(state.lifetime_restarts, 0);
11873        assert_eq!(state.crash_restarts.len(), 0);
11874    }
11875
11876    #[test]
11877    fn fatal_connection_teardown_cannot_arm_a_marker_for_a_surviving_process() {
11878        // The server's generic fatal-routing branch only knows that the
11879        // connection failed; it does not know that the daemon deliberately
11880        // initiated a process-killing severance. Keep this seam explicit so a
11881        // future connection error path cannot silently reintroduce the stale
11882        // exemption that mislabels a later genuine crash.
11883        assert!(!include_str!("server.rs")
11884            .contains("router.record_deliberate_connection_severance(ctx.connection_id)"));
11885    }
11886
11887    /// The `route.closed` `drained` value must be the quiescence wait's own
11888    /// measurement (`Ok`), never invented -- except on `Err`, where there is no
11889    /// measurement at all and `false` is the one honest constant. This is the exact
11890    /// logic `begin_forwarding_drain_with` now applies before sending `route.closed`
11891    /// on every return path, including the one that used to return early via `?`
11892    /// with `route.closing` already sent and no `route.closed` ever following.
11893    #[test]
11894    fn drained_after_quiescence_wait_passes_ok_through_and_forces_false_on_err() {
11895        assert!(drained_after_quiescence_wait(&Ok(true)));
11896        assert!(!drained_after_quiescence_wait(&Ok(false)));
11897        assert!(!drained_after_quiescence_wait(&Err(
11898            SuperviseError::StatePoisoned { module_id: None }
11899        )));
11900    }
11901
11902    /// `supervise_loop`'s `wait()`-error arm now calls `record_terminal` like every
11903    /// other exit path does, so a module whose child `wait()` itself errored (e.g.
11904    /// already reaped out-of-band) still leaves a terminal record rather than none
11905    /// at all. Triggering the real `wait()` I/O error from an integration test would
11906    /// need a genuine already-reaped-child race, which is OS-specific and not
11907    /// something this suite attempts elsewhere; this test instead verifies the
11908    /// record produced for that arm end-to-end through the real `TerminalRing`, and
11909    /// the call site itself is verified by inspection to sit in that exact arm.
11910    #[test]
11911    fn wait_error_exit_report_records_a_failed_terminal_with_no_code_or_signal() {
11912        let ring = Arc::new(Mutex::new(TerminalRing::new(
11913            TerminalRingConfig::default(),
11914            0,
11915        )));
11916        record_wait_error_terminal("wait-error", &ring, &super::SpawnEventFeed::default());
11917
11918        let snapshot = ring.lock().unwrap().snapshot();
11919        assert_eq!(snapshot.entries.len(), 1);
11920        let entry = &snapshot.entries[0];
11921        assert_eq!(entry.exit_code, None);
11922        assert_eq!(entry.exit_signal, None);
11923        assert_eq!(entry.disposition, TerminalDisposition::Failed);
11924    }
11925
11926    #[test]
11927    fn wait_error_exit_path_preserves_spawn_event_density() {
11928        let feed = super::SpawnEventFeed::default();
11929        feed.configure_incarnation("wait-error-density".to_string());
11930        feed.emit_spawned("wait-error", 41, 1);
11931        let ring = Arc::new(Mutex::new(TerminalRing::new(
11932            TerminalRingConfig::default(),
11933            0,
11934        )));
11935
11936        record_wait_error_terminal("wait-error", &ring, &feed);
11937        feed.emit_spawned("after-wait-error", 42, 2);
11938
11939        let state = feed.0.lock().unwrap();
11940        let sequences = state
11941            .events
11942            .iter()
11943            .map(|event| event.cursor.seq)
11944            .collect::<Vec<_>>();
11945        assert_eq!(sequences, vec![1, 2, 3]);
11946        assert_eq!(state.events[1].kind, SpawnEventKind::Exited);
11947        assert_eq!(state.events[1].exit_code, None);
11948        assert_eq!(state.events[1].exit_signal, None);
11949    }
11950
11951    /// Pins the report's `kind` too: the wait-error arm treats an unwaitable child
11952    /// as a crash (matching `fail_snapshot`'s `Failed` disposition for this arm),
11953    /// not a clean exit it never actually observed.
11954    #[test]
11955    fn wait_error_exit_report_is_classified_as_a_crash() {
11956        assert_eq!(wait_error_exit_report().kind, ExitKind::Crash);
11957    }
11958}
11959
11960#[cfg(all(test, unix))]
11961mod health_event_tests {
11962    use super::*;
11963    use cortexkit_test_support::ScratchDir;
11964    use subc_protocol::{manifest::Concurrency, session::ModuleControlResponse};
11965
11966    struct Actor {
11967        module: SupervisedModule,
11968        registry: Arc<Registry>,
11969        forwarding: Arc<ForwardingTable>,
11970        spec: ModuleSpec,
11971        health: HealthConfig,
11972        rx: mpsc::Receiver<crate::router::OutboundFrame>,
11973        _home: ScratchDir,
11974    }
11975
11976    async fn wait_for<T>(reason: &str, mut observe: impl FnMut() -> Option<T>) -> T {
11977        // OS exec and socket readiness are real I/O. Stay runnable while waiting
11978        // for an observed condition, rather than letting paused time auto-advance
11979        // deadlines or assuming a fixed number of yields finishes the work.
11980        let deadline = std::time::Instant::now() + Duration::from_secs(10);
11981        let tick = Instant::now();
11982        loop {
11983            if let Some(value) = observe() {
11984                assert_eq!(Instant::now(), tick, "{reason} must not wait for a timer");
11985                return value;
11986            }
11987            assert!(
11988                std::time::Instant::now() < deadline,
11989                "timed out waiting for {reason}"
11990            );
11991            tokio::task::yield_now().await;
11992        }
11993    }
11994
11995    impl Actor {
11996        async fn start(protocol: ModuleProtocol, cadence: Duration) -> Self {
11997            let home = ScratchDir::new("health-events");
11998            let registry = Arc::new(Registry::default());
11999            let forwarding = Arc::new(ForwardingTable::default());
12000            let health = HealthConfig {
12001                cadence,
12002                deadline: Duration::from_secs(3600),
12003                ..HealthConfig::default()
12004            };
12005            let supervisor =
12006                Supervisor::new_for_test(registry.clone(), RestartPolicy::new(3, Duration::ZERO))
12007                    .with_forwarding(forwarding.clone())
12008                    .with_health_config(health.clone());
12009            let spec = ModuleSpec {
12010                module_id: "event-health-child".into(),
12011                program: PathBuf::from("/bin/sh"),
12012                // The process stays alive but has no real bus peer. Registrations
12013                // below exercise the same registry writes as accepted HELLOs.
12014                args: vec!["-c".into(), "exec sleep 600".into()],
12015                env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
12016                    .into_iter()
12017                    .map(|key| (key.into(), home.path().display().to_string()))
12018                    .collect(),
12019                reserved: false,
12020                reserved_prefixes: vec![],
12021                protocol,
12022                overlap: Default::default(),
12023            };
12024            let module = supervisor.spawn(spec.clone()).unwrap();
12025            let (_, rx) = mpsc::channel(8);
12026            let actor = Self {
12027                module,
12028                registry,
12029                forwarding,
12030                spec,
12031                health,
12032                rx,
12033                _home: home,
12034            };
12035            actor
12036                .wait_for_select(|checkpoint| checkpoint.generation > 0)
12037                .await;
12038            actor
12039        }
12040
12041        fn turns(&self) -> u64 {
12042            lock_snapshot(&self.module.inner.snapshot)
12043                .unwrap()
12044                .actor_turns
12045        }
12046
12047        async fn wait_for_select(
12048            &self,
12049            expected: impl Fn(&ActorSelectCheckpoint) -> bool,
12050        ) -> ActorSelectCheckpoint {
12051            wait_for("actor select checkpoint", || {
12052                let state = lock_snapshot(&self.module.inner.snapshot).unwrap();
12053                state.actor_select.filter(|checkpoint| {
12054                    checkpoint.generation == state.spawn_generation
12055                        && state.state == ModuleState::Running
12056                        && state.process_alive
12057                        && state.reported_pid().is_some()
12058                        && expected(checkpoint)
12059                })
12060            })
12061            .await
12062        }
12063
12064        fn hello(&mut self, connection: u64, health: bool) {
12065            let manifest =
12066                subc_protocol::manifest::ModuleManifest::builder(&self.spec.module_id, "1.0.0")
12067                    .protocol_ver(subc_protocol::PROTOCOL_VERSION)
12068                    .build();
12069            let connection = ConnectionId::new(connection);
12070            let (tx, rx) = mpsc::channel(8);
12071            self.rx = rx;
12072            self.forwarding
12073                .register_module_connection(
12074                    connection,
12075                    self.spec.module_id.clone(),
12076                    subc_protocol::PROTOCOL_VERSION,
12077                    Concurrency::ModuleManaged,
12078                    FrameSink::new(tx),
12079                )
12080                .unwrap();
12081            self.registry
12082                .register_with_control_ops(
12083                    manifest,
12084                    subc_protocol::PROTOCOL_VERSION,
12085                    connection,
12086                    if health {
12087                        vec![MODULE_CONTROL_OP_HEALTH_CHECK.into()]
12088                    } else {
12089                        vec![]
12090                    },
12091                )
12092                .unwrap();
12093        }
12094
12095        async fn probe(&mut self) -> crate::router::OutboundFrame {
12096            let frame = wait_for("outbound health probe", || match self.rx.try_recv() {
12097                Ok(frame) => Some(frame),
12098                Err(mpsc::error::TryRecvError::Empty) => None,
12099                Err(mpsc::error::TryRecvError::Disconnected) => panic!("health peer disconnected"),
12100            })
12101            .await;
12102            let body: Value = serde_json::from_slice(&frame.body).unwrap();
12103            assert_eq!(body["op"], MODULE_CONTROL_OP_HEALTH_CHECK);
12104            frame
12105        }
12106
12107        fn no_probe(&mut self) {
12108            assert!(matches!(
12109                self.rx.try_recv(),
12110                Err(mpsc::error::TryRecvError::Empty)
12111            ));
12112        }
12113
12114        fn answer(&self, connection: u64, frame: crate::router::OutboundFrame) {
12115            self.forwarding
12116                .complete_module_control_rpc(
12117                    ConnectionId::new(connection),
12118                    frame.header.corr,
12119                    Some(MODULE_CONTROL_OP_HEALTH_CHECK),
12120                    ModuleControlRpcOutcome::Response(ModuleControlResponse::HealthCheck {
12121                        status: HealthStatus::Ok,
12122                        detail: None,
12123                        metrics: None,
12124                    }),
12125                )
12126                .unwrap();
12127        }
12128    }
12129
12130    #[tokio::test(start_paused = true)]
12131    async fn no_health_children_park_for_sixty_seconds() {
12132        let none = Actor::start(ModuleProtocol::None, Duration::from_secs(30)).await;
12133        let unregistered = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12134        let mut unadvertised = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12135        unadvertised.hello(1, false);
12136        // Include any one-off registration wake in the budget; receiving a
12137        // no-health HELLO must not turn a parked child into a polling child.
12138        // Advance in 10 ms steps so the old poll really executes ~6,000 turns;
12139        // a single 60 s jump would only observe one expired timer.
12140        for _ in 0..6000 {
12141            tokio::time::advance(Duration::from_millis(10)).await;
12142            tokio::task::yield_now().await;
12143        }
12144        for actor in [&none, &unregistered, &unadvertised] {
12145            assert!(
12146                actor.turns() <= 5,
12147                "no-health actor woke {} times",
12148                actor.turns()
12149            );
12150            assert_eq!(actor.module.state().unwrap(), ModuleState::Running);
12151        }
12152    }
12153
12154    #[tokio::test(start_paused = true)]
12155    async fn late_hello_starts_a_due_probe_in_the_notification_tick() {
12156        // With a zero cadence the first probe is due at once, so this measures
12157        // only how fast the registration notification starts health probing,
12158        // not the normal 30-33 s wait before a first probe (checked below).
12159        let mut actor = Actor::start(ModuleProtocol::Subc, Duration::ZERO).await;
12160        let tick = Instant::now();
12161        actor.hello(1, true);
12162        actor.probe().await;
12163        assert_eq!(Instant::now(), tick, "HELLO must not wait for a poll");
12164    }
12165
12166    #[tokio::test(start_paused = true)]
12167    async fn hello_keeps_the_jittered_cadence_and_catalog_updates_do_not_reset_it() {
12168        let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12169        let hello_tick = Instant::now();
12170        actor.hello(1, true);
12171        let armed = actor
12172            .wait_for_select(|checkpoint| {
12173                checkpoint.registered_connection == Some(ConnectionId::new(1))
12174                    && checkpoint.next_probe_at.is_some()
12175            })
12176            .await;
12177        let delay = jittered_health_delay(&actor.spec.module_id, 0, actor.health.cadence);
12178        assert!((Duration::from_secs(30)..Duration::from_secs(33)).contains(&delay));
12179        let first_deadline = hello_tick + delay;
12180        assert_eq!(armed.next_probe_at, Some(first_deadline));
12181        tokio::time::advance(Duration::from_secs(29)).await;
12182        let turns = actor.turns();
12183        actor
12184            .registry
12185            .replace_catalog_for_connection(ConnectionId::new(1), vec![], None, Some(true))
12186            .unwrap();
12187        let updated = actor
12188            .wait_for_select(|checkpoint| checkpoint.turn > turns)
12189            .await;
12190        assert_eq!(
12191            updated.next_probe_at,
12192            Some(first_deadline),
12193            "catalog update must not reset cadence"
12194        );
12195        actor.no_probe();
12196        tokio::time::advance(first_deadline - Instant::now()).await;
12197        let frame = actor.probe().await;
12198        actor.answer(1, frame);
12199        let next = jittered_health_delay(&actor.spec.module_id, 1, actor.health.cadence);
12200        let next_deadline = Instant::now() + next;
12201        let rearmed = actor
12202            .wait_for_select(|checkpoint| {
12203                checkpoint
12204                    .next_probe_at
12205                    .is_some_and(|deadline| deadline > first_deadline)
12206            })
12207            .await;
12208        assert_eq!(rearmed.next_probe_at, Some(next_deadline));
12209        tokio::time::advance(next - Duration::from_nanos(1)).await;
12210        actor.no_probe();
12211        tokio::time::advance(Duration::from_nanos(1)).await;
12212        actor.probe().await;
12213    }
12214
12215    #[tokio::test(start_paused = true)]
12216    async fn disconnect_disarms_and_reconnect_rearms_health() {
12217        let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12218        actor.hello(1, true);
12219        actor
12220            .wait_for_select(|checkpoint| {
12221                checkpoint.registered_connection == Some(ConnectionId::new(1))
12222                    && checkpoint.next_probe_at.is_some()
12223            })
12224            .await;
12225        let turns = actor.turns();
12226        let tick = Instant::now();
12227        actor
12228            .registry
12229            .deregister_connection(ConnectionId::new(1))
12230            .unwrap();
12231        let disarmed = actor
12232            .wait_for_select(|checkpoint| {
12233                checkpoint.turn > turns && checkpoint.registered_connection.is_none()
12234            })
12235            .await;
12236        assert_eq!(disarmed.next_probe_at, None);
12237        assert_eq!(disarmed.wake_after, None);
12238        assert_eq!(
12239            Instant::now(),
12240            tick,
12241            "disconnect must disarm in the notification tick"
12242        );
12243        tokio::time::advance(Duration::from_secs(60)).await;
12244        actor.no_probe();
12245        let reconnect_tick = Instant::now();
12246        actor.hello(2, true);
12247        let rearmed = actor
12248            .wait_for_select(|checkpoint| {
12249                checkpoint.registered_connection == Some(ConnectionId::new(2))
12250                    && checkpoint.next_probe_at.is_some()
12251            })
12252            .await;
12253        let delay = jittered_health_delay(&actor.spec.module_id, 0, actor.health.cadence);
12254        assert_eq!(rearmed.next_probe_at, Some(reconnect_tick + delay));
12255        tokio::time::advance(delay).await;
12256        actor.probe().await;
12257    }
12258
12259    #[tokio::test(start_paused = true)]
12260    async fn reregistration_without_health_disarms_the_old_schedule() {
12261        let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12262        actor.hello(1, true);
12263        actor
12264            .wait_for_select(|checkpoint| {
12265                checkpoint.registered_connection == Some(ConnectionId::new(1))
12266                    && checkpoint.next_probe_at.is_some()
12267            })
12268            .await;
12269        actor
12270            .registry
12271            .deregister_connection(ConnectionId::new(1))
12272            .unwrap();
12273        actor.hello(2, false);
12274        let disarmed = actor
12275            .wait_for_select(|checkpoint| {
12276                checkpoint.registered_connection == Some(ConnectionId::new(2))
12277            })
12278            .await;
12279        assert_eq!(disarmed.next_probe_at, None);
12280        assert_eq!(disarmed.wake_after, None);
12281        tokio::time::advance(Duration::from_secs(60)).await;
12282        actor.no_probe();
12283    }
12284
12285    #[tokio::test(start_paused = true)]
12286    async fn restart_waits_for_its_own_hello_before_rearming_health() {
12287        let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12288        actor.hello(1, true);
12289        let armed = actor
12290            .wait_for_select(|checkpoint| {
12291                checkpoint.registered_connection == Some(ConnectionId::new(1))
12292                    && checkpoint.next_probe_at.is_some()
12293            })
12294            .await;
12295        // Simulate the old process's connection closing before the restart
12296        // tears the process down. The replacement process has started but has
12297        // not sent its HELLO, so it is not registered.
12298        actor
12299            .registry
12300            .deregister_connection(ConnectionId::new(1))
12301            .unwrap();
12302        actor.module.restart(Some(0)).await.unwrap();
12303        let parked = actor
12304            .wait_for_select(|checkpoint| {
12305                checkpoint.generation > armed.generation
12306                    && checkpoint.registered_connection.is_none()
12307                    && checkpoint.next_probe_at.is_none()
12308            })
12309            .await;
12310        assert_eq!(parked.wake_after, None);
12311        actor.rx.close();
12312        // Drain the stop notice the restart queued for the old process's connection,
12313        // so it isn't mistaken for traffic to the replacement.
12314        while actor.rx.try_recv().is_ok() {}
12315        let turns = actor.turns();
12316        tokio::time::advance(Duration::from_secs(60)).await;
12317        let after = actor.turns();
12318        eprintln!(
12319            "restart park turns: before={turns} after={after} delta={}",
12320            after - turns
12321        );
12322        assert_eq!(after, turns, "replacement must park before HELLO");
12323        let hello_tick = Instant::now();
12324        actor.hello(2, true);
12325        let rearmed = actor
12326            .wait_for_select(|checkpoint| {
12327                checkpoint.registered_connection == Some(ConnectionId::new(2))
12328                    && checkpoint.next_probe_at.is_some()
12329            })
12330            .await;
12331        let delay = jittered_health_delay(&actor.spec.module_id, 0, actor.health.cadence);
12332        assert_eq!(rearmed.next_probe_at, Some(hello_tick + delay));
12333        tokio::time::advance(delay).await;
12334        actor.probe().await;
12335    }
12336
12337    #[tokio::test(start_paused = true)]
12338    async fn rescan_adds_and_removes_http_health_without_polling() {
12339        use tokio::{
12340            io::{AsyncReadExt, AsyncWriteExt},
12341            net::TcpListener,
12342        };
12343        let mut actor = Actor::start(ModuleProtocol::None, Duration::from_secs(30)).await;
12344        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
12345        actor.health.http = Some(format!("http://{}/health", listener.local_addr().unwrap()));
12346        // One millisecond separates successive probes without a polling delay.
12347        actor.health.cadence = Duration::from_millis(1);
12348        let (started_tx, mut started_rx) = mpsc::channel(8);
12349        let peer = tokio::spawn(async move {
12350            loop {
12351                let (mut socket, _) = listener.accept().await.unwrap();
12352                let mut request = [0; 1024];
12353                assert!(socket.read(&mut request).await.unwrap() > 0);
12354                started_tx.send(()).await.unwrap();
12355                socket
12356                    .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
12357                    .await
12358                    .unwrap();
12359            }
12360        });
12361        let tick = Instant::now();
12362        let turns = actor.turns();
12363        actor
12364            .module
12365            .update_configuration(actor.spec.clone(), actor.health.clone(), None)
12366            .await
12367            .unwrap();
12368        let armed = actor
12369            .wait_for_select(|checkpoint| {
12370                checkpoint.turn > turns && checkpoint.next_probe_at.is_some()
12371            })
12372            .await;
12373        assert_eq!(armed.next_probe_at, Some(tick + Duration::from_millis(1)));
12374        assert_eq!(Instant::now(), tick);
12375        tokio::time::advance(Duration::from_millis(1)).await;
12376        let mut started = false;
12377        wait_for("successful HTTP probe", || {
12378            started |= started_rx.try_recv().is_ok();
12379            (started && actor.module.status().unwrap().health.status == SupervisorHealthStatus::Ok)
12380                .then_some(())
12381        })
12382        .await;
12383        assert!(started, "added HTTP check must start at its first deadline");
12384        assert_eq!(
12385            actor.module.status().unwrap().health.status,
12386            SupervisorHealthStatus::Ok
12387        );
12388        assert_eq!(Instant::now(), tick + Duration::from_millis(1));
12389        actor.health.http = None;
12390        let turns = actor.turns();
12391        actor
12392            .module
12393            .update_configuration(actor.spec.clone(), actor.health.clone(), None)
12394            .await
12395            .unwrap();
12396        let parked = actor
12397            .wait_for_select(|checkpoint| {
12398                checkpoint.turn > turns && checkpoint.next_probe_at.is_none()
12399            })
12400            .await;
12401        assert_eq!(parked.wake_after, None);
12402        let turns = actor.turns();
12403        tokio::time::advance(Duration::from_secs(60)).await;
12404        assert_eq!(
12405            actor.turns(),
12406            turns,
12407            "removed HTTP check must park the actor"
12408        );
12409        assert!(
12410            started_rx.try_recv().is_err(),
12411            "removed HTTP check must stay disarmed"
12412        );
12413        assert_eq!(
12414            actor.module.status().unwrap().health,
12415            ModuleHealthStatus::default()
12416        );
12417        peer.abort();
12418    }
12419}
12420
12421#[cfg(test)]
12422mod health_evidence_tests {
12423    use super::{HealthProbeError, HealthProbeEvidence};
12424    use std::collections::HashSet;
12425
12426    /// The evidential asymmetry, asserted rather than described.
12427    ///
12428    /// Exactly ONE observation is proof a module cannot serve, and the one that
12429    /// fires under CPU starvation is not it. Before the split, all fifteen
12430    /// construction sites collapsed into a single String, so a timeout carried the
12431    /// same weight as a dead lane -- which is how a healthy module was restarted
12432    /// three times in one day.
12433    #[test]
12434    fn only_a_dead_lane_is_proof_of_death() {
12435        assert!(HealthProbeError::lane_dead("gone").is_proof_of_death());
12436        // Three non-proof classes, each for a different reason: silence is
12437        // consistent with health, a bad answer proves the module ALIVE, and a
12438        // daemon-side fault never reached the module at all.
12439        assert!(!HealthProbeError::no_answer("timed out").is_proof_of_death());
12440        assert!(!HealthProbeError::bad_answer("garbage").is_proof_of_death());
12441        assert!(!HealthProbeError::misconfigured("no table").is_proof_of_death());
12442    }
12443
12444    /// Labels must be distinct, or the operator-facing distinction is cosmetic.
12445    ///
12446    /// A shared label renders two different observations identically in the line an
12447    /// operator reads after an unexplained restart -- the exact confusion this
12448    /// change removes.
12449    #[test]
12450    fn every_evidence_class_has_a_distinct_label() {
12451        let labels = [
12452            HealthProbeError::lane_dead("").label(),
12453            HealthProbeError::no_answer("").label(),
12454            HealthProbeError::bad_answer("").label(),
12455            HealthProbeError::misconfigured("").label(),
12456        ];
12457        let unique: HashSet<_> = labels.iter().collect();
12458        assert_eq!(unique.len(), labels.len(), "labels collided: {labels:?}");
12459    }
12460
12461    /// The class is additional information, not a replacement.
12462    ///
12463    /// An operator needs both "this was silence" and the specific text saying how
12464    /// long we waited; a classification that swallowed the message would trade one
12465    /// missing distinction for another.
12466    #[test]
12467    fn classification_preserves_the_original_message() {
12468        let err = HealthProbeError::no_answer("module did not answer within 5s");
12469        assert_eq!(err.to_string(), "module did not answer within 5s");
12470        assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
12471    }
12472}
12473
12474#[cfg(test)]
12475mod health_tombstone_tests {
12476    use std::{path::PathBuf, sync::Arc, time::Duration};
12477
12478    use subc_protocol::{
12479        manifest::Concurrency,
12480        session::{HealthStatus, ModuleControlResponse},
12481    };
12482    use tokio::sync::mpsc;
12483
12484    use super::{
12485        probe_module_health, HealthAction, HealthConfig, HealthProbeEvidence, ModuleProtocol,
12486        ModuleSpec, RestartPolicy, Supervisor, SupervisorRuntimeConfig,
12487    };
12488    use crate::{
12489        control::ControlHandler,
12490        forwarding::{ForwardingTable, ModuleControlRpcCompletion, ModuleControlRpcOutcome},
12491        registry::{ConnectionId, Registry},
12492        router::FrameSink,
12493    };
12494
12495    struct ProbeHarness {
12496        spec: ModuleSpec,
12497        runtime: SupervisorRuntimeConfig,
12498        forwarding: Arc<ForwardingTable>,
12499        module_connection: ConnectionId,
12500        module_rx: mpsc::Receiver<crate::router::OutboundFrame>,
12501        handler: ControlHandler,
12502        module: super::SupervisedModule,
12503    }
12504
12505    fn probe_harness() -> ProbeHarness {
12506        let registry = Arc::new(Registry::default());
12507        let forwarding = Arc::new(ForwardingTable::default());
12508        let supervisor_handle = super::SupervisorHandle::new();
12509        let health = HealthConfig {
12510            http: None,
12511            cadence: Duration::from_secs(30),
12512            deadline: Duration::from_secs(5),
12513            failure_threshold: 3,
12514            on_degraded: HealthAction::Report,
12515            on_failing: HealthAction::Report,
12516            critical: false,
12517        };
12518        let supervisor = Supervisor::new_for_test(Arc::clone(&registry), RestartPolicy::default())
12519            .with_forwarding(Arc::clone(&forwarding))
12520            .with_handle(supervisor_handle.clone())
12521            .with_health_config(health);
12522        let spec = ModuleSpec {
12523            module_id: "late-health-module".to_string(),
12524            program: PathBuf::from("disabled-module"),
12525            args: Vec::new(),
12526            env: Vec::new(),
12527            reserved: false,
12528            reserved_prefixes: Vec::new(),
12529            protocol: ModuleProtocol::Subc,
12530            overlap: Default::default(),
12531        };
12532        let module = supervisor
12533            .supervise_configured(spec.clone(), false)
12534            .unwrap();
12535        let runtime = supervisor.runtime_config();
12536        let handler = ControlHandler::with_forwarding(registry, Arc::clone(&forwarding))
12537            .with_supervisor(supervisor_handle);
12538        let module_connection = ConnectionId::new(700);
12539        let (module_tx, module_rx) = mpsc::channel(8);
12540        forwarding
12541            .register_module_connection(
12542                module_connection,
12543                spec.module_id.clone(),
12544                subc_protocol::PROTOCOL_VERSION,
12545                Concurrency::ModuleManaged,
12546                FrameSink::new(module_tx),
12547            )
12548            .unwrap();
12549
12550        ProbeHarness {
12551            spec,
12552            runtime,
12553            forwarding,
12554            module_connection,
12555            module_rx,
12556            handler,
12557            module,
12558        }
12559    }
12560
12561    async fn finish_after(
12562        harness: &mut ProbeHarness,
12563        stall: Duration,
12564    ) -> ModuleControlRpcCompletion {
12565        assert!(stall > harness.runtime.health.deadline);
12566        let deadline = harness.runtime.health.deadline;
12567        let probe = probe_module_health(&harness.spec.module_id, &harness.runtime, None);
12568        let answer = async {
12569            let frame = harness.module_rx.recv().await.expect("health.check frame");
12570            tokio::time::advance(deadline).await;
12571            tokio::task::yield_now().await;
12572            tokio::time::advance(stall - deadline).await;
12573            harness
12574                .forwarding
12575                .complete_module_control_rpc(
12576                    harness.module_connection,
12577                    frame.header.corr,
12578                    Some("health.check"),
12579                    ModuleControlRpcOutcome::Response(ModuleControlResponse::HealthCheck {
12580                        status: HealthStatus::Ok,
12581                        detail: None,
12582                        metrics: None,
12583                    }),
12584                )
12585                .unwrap()
12586        };
12587        let (probe_result, completion) = tokio::join!(probe, answer);
12588        let err = probe_result.expect_err("probe must miss its deadline");
12589        assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
12590        completion
12591    }
12592
12593    async fn time_out_without_answer(harness: &mut ProbeHarness) {
12594        let deadline = harness.runtime.health.deadline;
12595        let probe = probe_module_health(&harness.spec.module_id, &harness.runtime, None);
12596        let exhaust_deadline = async {
12597            let _frame = harness.module_rx.recv().await.expect("health.check frame");
12598            tokio::time::advance(deadline).await;
12599            tokio::task::yield_now().await;
12600        };
12601        let (probe_result, ()) = tokio::join!(probe, exhaust_deadline);
12602        let err = probe_result.expect_err("probe must miss its deadline");
12603        assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
12604    }
12605
12606    async fn run_probe_cycle(harness: &mut ProbeHarness, answer: bool) {
12607        let registry = Arc::clone(&harness.module.inner.registry);
12608        let snapshot = Arc::clone(&harness.module.inner.snapshot);
12609        let process_liveness = super::SupervisorProcessLiveness::default();
12610        let mut child = None;
12611        let cycle = super::run_health_probe_cycle(
12612            &harness.spec,
12613            &harness.runtime,
12614            &registry,
12615            &process_liveness,
12616            &snapshot,
12617            &mut child,
12618        );
12619        let peer = async {
12620            let frame = harness.module_rx.recv().await.expect("health.check frame");
12621            if answer {
12622                harness
12623                    .forwarding
12624                    .complete_module_control_rpc(
12625                        harness.module_connection,
12626                        frame.header.corr,
12627                        Some("health.check"),
12628                        ModuleControlRpcOutcome::Response(ModuleControlResponse::HealthCheck {
12629                            status: HealthStatus::Ok,
12630                            detail: None,
12631                            metrics: Some(serde_json::json!({"ready": true})),
12632                        }),
12633                    )
12634                    .unwrap();
12635            } else {
12636                tokio::time::advance(harness.runtime.health.deadline).await;
12637                tokio::task::yield_now().await;
12638            }
12639        };
12640        tokio::join!(cycle, peer);
12641    }
12642
12643    #[tokio::test(start_paused = true)]
12644    async fn unanswered_probe_is_unknown_until_threshold_and_ok_report_recovers() {
12645        let mut harness = probe_harness();
12646        // Drive the probe cycle directly with an in-memory wire peer. Stop the
12647        // disabled module's monitor so only this test owns lifecycle transitions;
12648        // no OS process is launched, and a restart is observed at scheduling.
12649        let monitor = harness.module.inner.monitor.lock().unwrap().take().unwrap();
12650        monitor.abort();
12651        let _ = monitor.await;
12652        super::update_snapshot(&harness.module.inner.snapshot, None, |state| {
12653            state.enabled = true;
12654            state.state = super::ModuleState::Running;
12655            state.process_alive = true;
12656        })
12657        .unwrap();
12658
12659        run_probe_cycle(&mut harness, true).await;
12660        assert_eq!(
12661            harness.module.status().unwrap().health.status,
12662            super::SupervisorHealthStatus::Ok
12663        );
12664
12665        for failures in 1..harness.runtime.health.failure_threshold {
12666            run_probe_cycle(&mut harness, false).await;
12667            let status = harness.module.status().unwrap();
12668            assert_eq!(status.health.status, super::SupervisorHealthStatus::Unknown);
12669            assert_eq!(status.health.consecutive_failures, failures);
12670            assert!(status.health.last_probe_ms.is_some());
12671            assert!(status.health.detail.unwrap().starts_with("[no-answer]"));
12672            assert!(status.health.metrics.is_none());
12673            assert_eq!(status.state, super::ModuleState::Running);
12674            assert!(status.process_alive);
12675            assert_eq!(status.restart_count, 0);
12676            assert_eq!(status.lifetime_restarts, 0);
12677            assert!(status.health.last_action.is_none());
12678            assert!(harness.runtime.scheduled_respawn.lock().unwrap().is_none());
12679        }
12680
12681        run_probe_cycle(&mut harness, true).await;
12682        let recovered = harness.module.status().unwrap();
12683        assert_eq!(recovered.health.status, super::SupervisorHealthStatus::Ok);
12684        assert_eq!(recovered.health.consecutive_failures, 0);
12685        assert!(recovered.health.detail.is_none());
12686        assert_eq!(
12687            recovered.health.metrics,
12688            Some(serde_json::json!({"ready": true}))
12689        );
12690        assert_eq!(recovered.lifetime_restarts, 0);
12691
12692        for failures in 1..=harness.runtime.health.failure_threshold {
12693            run_probe_cycle(&mut harness, false).await;
12694            let status = harness.module.status().unwrap();
12695            assert_eq!(status.health.consecutive_failures, failures);
12696            if failures < harness.runtime.health.failure_threshold {
12697                assert_eq!(status.health.status, super::SupervisorHealthStatus::Unknown);
12698                assert_eq!(status.state, super::ModuleState::Running);
12699                assert_eq!(status.lifetime_restarts, 0);
12700                assert!(harness.runtime.scheduled_respawn.lock().unwrap().is_none());
12701            } else {
12702                assert_eq!(
12703                    status.health.status,
12704                    super::SupervisorHealthStatus::Unresponsive
12705                );
12706                assert_eq!(status.state, super::ModuleState::Restarting);
12707                assert_eq!(status.restart_count, 1);
12708                assert_eq!(status.lifetime_restarts, 1);
12709                assert_eq!(status.health.last_action.as_deref(), Some("restart"));
12710                assert!(harness.runtime.scheduled_respawn.lock().unwrap().is_some());
12711            }
12712        }
12713    }
12714
12715    #[tokio::test(start_paused = true)]
12716    async fn late_health_answers_record_start_anchored_latency_for_two_stalls() {
12717        let mut harness = probe_harness();
12718
12719        let first = finish_after(&mut harness, Duration::from_secs(8)).await;
12720        let first_latency = match &first {
12721            ModuleControlRpcCompletion::LateHealthAnswer { latency, .. } => *latency,
12722            other => panic!("late answer was not retained: {other:?}"),
12723        };
12724        assert!(harness.handler.observe_module_control_completion(first));
12725
12726        let second = finish_after(&mut harness, Duration::from_secs(11)).await;
12727        let second_latency = match &second {
12728            ModuleControlRpcCompletion::LateHealthAnswer { latency, .. } => *latency,
12729            other => panic!("late answer was not retained: {other:?}"),
12730        };
12731        assert!(harness.handler.observe_module_control_completion(second));
12732
12733        assert_eq!(first_latency, Duration::from_secs(8));
12734        assert_eq!(
12735            second_latency - first_latency,
12736            Duration::from_secs(3),
12737            "latency must grow linearly with the additional stall"
12738        );
12739        let health = harness.module.status().unwrap().health;
12740        assert_eq!(health.late_answer_count, 2);
12741        assert_eq!(health.last_late_answer_latency_ms, Some(11_000));
12742    }
12743
12744    /// A module that answers every probe late must never march to the kill
12745    /// threshold: the late answer proves it is alive, so it must clear the miss
12746    /// streak the timeout recorded. Without the reset, a CPU-starved module
12747    /// that serves every probe seconds past the deadline accumulates
12748    /// `consecutive_failures` to the threshold and is killed — the exact
12749    /// sequence from the 2026-08-14 aft disable, where the daemon logged
12750    /// "proves the module is alive" five times while counting five misses.
12751    #[tokio::test(start_paused = true)]
12752    async fn late_answer_clears_the_consecutive_failure_streak() {
12753        let mut harness = probe_harness();
12754
12755        // Timeout recorded first: the probe path saw no answer in time.
12756        time_out_without_answer(&mut harness).await;
12757        harness
12758            .module
12759            .record_health_probe_failure_for_test("[no-answer] test miss")
12760            .unwrap();
12761        assert_eq!(
12762            harness.module.status().unwrap().health.consecutive_failures,
12763            1,
12764            "precondition: the miss must be on the streak before the late answer"
12765        );
12766
12767        // The stalled reply then lands: proof of life.
12768        let late = finish_after(&mut harness, Duration::from_secs(9)).await;
12769        assert!(matches!(
12770            late,
12771            ModuleControlRpcCompletion::LateHealthAnswer { .. }
12772        ));
12773        assert!(harness.handler.observe_module_control_completion(late));
12774
12775        let health = harness.module.status().unwrap().health;
12776        assert_eq!(
12777            health.consecutive_failures, 0,
12778            "a late answer is an answer: the streak must reset"
12779        );
12780        assert_eq!(health.late_answer_count, 1);
12781    }
12782
12783    #[tokio::test(start_paused = true)]
12784    async fn repeated_serial_probe_cycles_keep_one_tombstone_per_endpoint() {
12785        let mut harness = probe_harness();
12786
12787        for _ in 0..20 {
12788            time_out_without_answer(&mut harness).await;
12789            assert_eq!(
12790                harness.forwarding.health_probe_tombstone_count().unwrap(),
12791                1
12792            );
12793        }
12794    }
12795}
12796
12797#[cfg(test)]
12798mod child_env_tests {
12799    #[cfg(windows)]
12800    use super::{
12801        clear_current_process_facts, observed_launch_nonce_source, ModuleState, SupervisorSnapshot,
12802    };
12803    #[cfg(windows)]
12804    use std::sync::{Arc, OnceLock};
12805    #[cfg(windows)]
12806    #[test]
12807    fn windows_wire_spawn_prepares_pipe_and_keeps_phase_one_env() {
12808        let spec = spec(Vec::new());
12809        let mut command = Command::new("unused");
12810        let handoff = apply_wire_spawn_args(&mut command, &spec, None, None)
12811            .unwrap()
12812            .unwrap();
12813        let name = command
12814            .as_std()
12815            .get_envs()
12816            .find(|(key, _)| *key == OsStr::new(subc_os::launch_nonce::LAUNCH_NONCE_PIPE_ENV))
12817            .and_then(|(_, value)| value)
12818            .unwrap();
12819        assert_eq!(name, OsStr::new(handoff.name()));
12820        assert!(handoff.name().starts_with(r"\\.\pipe\subc-launch-"));
12821        assert!(command.as_std().get_envs().any(|(key, value)| key
12822            == OsStr::new(subc_os::LAUNCH_NONCE_PIPE_FALLBACK_ENV)
12823            && value == Some(OsStr::new("env"))));
12824        assert!(command
12825            .as_std()
12826            .get_envs()
12827            .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some()));
12828    }
12829
12830    #[cfg(windows)]
12831    #[test]
12832    fn windows_source_reporting_tracks_consumption_and_resets_per_spawn() {
12833        let mut snapshot = SupervisorSnapshot::new(ModuleState::Running, true);
12834        snapshot.process_alive = true;
12835        snapshot.spawned_protocol = Some(ModuleProtocol::Subc);
12836        assert_eq!(
12837            observed_launch_nonce_source(&snapshot).as_deref(),
12838            Some("env")
12839        );
12840        let consumed = Arc::new(OnceLock::new());
12841        snapshot.pipe_consumed = Some(consumed.clone());
12842        assert_eq!(
12843            observed_launch_nonce_source(&snapshot).as_deref(),
12844            Some("env")
12845        );
12846        consumed.set(()).unwrap();
12847        assert_eq!(
12848            observed_launch_nonce_source(&snapshot).as_deref(),
12849            Some("pipe")
12850        );
12851        snapshot.pipe_consumed = Some(Arc::new(OnceLock::new()));
12852        assert_eq!(
12853            observed_launch_nonce_source(&snapshot).as_deref(),
12854            Some("env")
12855        );
12856        clear_current_process_facts(&mut snapshot);
12857        assert_eq!(observed_launch_nonce_source(&snapshot), None);
12858    }
12859    use super::{
12860        apply_child_env, apply_spawn_role, apply_wire_spawn_args, ModuleProtocol, ModuleSpec,
12861        SpawnRole, SupervisorHandle, SPAWN_ROLE_SWAP_CANDIDATE, SUBC_ARG, SUBC_LAUNCH_NONCE_ENV,
12862        SUBC_MODULE_ID_ENV, SUBC_SPAWN_ROLE_ENV,
12863    };
12864    use std::{ffi::OsStr, path::PathBuf};
12865    use tokio::process::Command;
12866
12867    fn spec(env: Vec<(String, String)>) -> ModuleSpec {
12868        ModuleSpec {
12869            module_id: "env-plan".to_string(),
12870            program: PathBuf::from("/nonexistent"),
12871            args: Vec::new(),
12872            env,
12873            reserved: false,
12874            reserved_prefixes: Vec::new(),
12875            protocol: ModuleProtocol::Subc,
12876            overlap: Default::default(),
12877        }
12878    }
12879
12880    /// Ambient `CK_LOG` is REMOVED for an unconfigured module, and a configured
12881    /// one still gets its own.
12882    ///
12883    /// This is the narrow goal `env_clear()` was reached for, and the reason the
12884    /// fix is `env_remove` rather than deleting the line: an operator's ambient
12885    /// filter silently becoming an unconfigured module's log level is a real
12886    /// defect, just a much smaller one than clearing the environment.
12887    ///
12888    /// Asserted on the command plan rather than a spawned child because proving
12889    /// the ABSENCE of an inherited variable needs the parent's environment
12890    /// mutated, and `forbid(unsafe_code)` refuses that. `get_envs()` reports a
12891    /// removal as `(key, None)`, which is exactly the distinction wanted: not
12892    /// "absent because nobody set it" but "explicitly unset for the child".
12893    #[test]
12894    fn ambient_ck_log_is_removed_and_a_configured_one_survives() {
12895        let mut command = Command::new("/nonexistent");
12896        apply_child_env(&mut command, &spec(Vec::new()));
12897        let removed = command
12898            .as_std()
12899            .get_envs()
12900            .any(|(key, value)| key == OsStr::new("CK_LOG") && value.is_none());
12901        assert!(
12902            removed,
12903            "ambient CK_LOG must be explicitly removed for an unconfigured module"
12904        );
12905
12906        let mut configured = Command::new("/nonexistent");
12907        apply_child_env(
12908            &mut configured,
12909            &spec(vec![("CK_LOG".to_string(), "debug".to_string())]),
12910        );
12911        let effective = configured
12912            .as_std()
12913            .get_envs()
12914            .filter(|(key, _)| *key == OsStr::new("CK_LOG"))
12915            .last()
12916            .map(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()));
12917        assert_eq!(
12918            effective,
12919            Some(Some("debug".to_string())),
12920            "a module's configured CK_LOG must survive the ambient removal"
12921        );
12922    }
12923
12924    /// A `protocol: "none"` spawn carries NO `--subc` argument and NO launch
12925    /// nonce; a subc-wire spawn carries both. Asserted on the command plan for
12926    /// the same reason as the CK_LOG test above.
12927    ///
12928    /// The argument is the load-bearing half: a stock binary exits on an
12929    /// unknown flag before it listens, so with `--subc` appended the mode
12930    /// could not supervise the one process it exists for. Found by the first
12931    /// conformance run (nats-server: `flag provided but not defined: -subc`).
12932    #[test]
12933    fn protocol_none_spawn_carries_no_subc_argument_and_no_nonce() {
12934        let connection_file = std::path::Path::new("/run/subc-connection.json");
12935        let handle = SupervisorHandle::new();
12936
12937        let mut none_spec = spec(Vec::new());
12938        none_spec.protocol = ModuleProtocol::None;
12939        let mut none = Command::new("/nonexistent");
12940        let none_handoff =
12941            apply_wire_spawn_args(&mut none, &none_spec, Some(connection_file), Some(&handle))
12942                .expect("protocol-none spawn args apply");
12943        assert!(
12944            none_handoff.is_none(),
12945            "protocol:none spawn must not receive a nonce descriptor"
12946        );
12947        #[cfg(windows)]
12948        assert!(!none.as_std().get_envs().any(|(key, value)| key
12949            == OsStr::new(subc_os::LAUNCH_NONCE_PIPE_FALLBACK_ENV)
12950            && value.is_some()));
12951        assert!(
12952            !none.as_std().get_envs().any(|(key, value)| key
12953                == OsStr::new(subc_os::LAUNCH_NONCE_FD_ENV)
12954                && value.is_some()),
12955            "protocol:none spawn must not name a nonce descriptor"
12956        );
12957        let none_args: Vec<String> = none
12958            .as_std()
12959            .get_args()
12960            .map(|a| a.to_string_lossy().into_owned())
12961            .collect();
12962        assert!(
12963            !none_args.iter().any(|a| a == SUBC_ARG),
12964            "protocol:none argv must not carry --subc; got {none_args:?}"
12965        );
12966        let none_has_nonce = none
12967            .as_std()
12968            .get_envs()
12969            .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some());
12970        assert!(
12971            !none_has_nonce,
12972            "protocol:none spawn must not receive a launch nonce"
12973        );
12974        let none_has_module_id = none
12975            .as_std()
12976            .get_envs()
12977            .any(|(key, value)| key == OsStr::new(SUBC_MODULE_ID_ENV) && value.is_some());
12978        assert!(
12979            none_has_module_id,
12980            "SUBC_MODULE_ID is inert and stays on every path"
12981        );
12982        assert!(
12983            handle.spawn_nonce(&none_spec.module_id).is_none(),
12984            "no nonce record for a process that will never present one"
12985        );
12986
12987        // Control: the subc-wire path is unchanged by the branch above.
12988        let wire_spec = spec(Vec::new());
12989        let mut wire = Command::new("/nonexistent");
12990        let wire_handoff =
12991            apply_wire_spawn_args(&mut wire, &wire_spec, Some(connection_file), Some(&handle))
12992                .expect("subc-wire spawn args apply");
12993        let wire_fd_env = wire
12994            .as_std()
12995            .get_envs()
12996            .find(|(key, _)| *key == OsStr::new(subc_os::LAUNCH_NONCE_FD_ENV))
12997            .and_then(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()));
12998        #[cfg(unix)]
12999        assert_eq!(
13000            wire_fd_env,
13001            Some(wire_handoff.expect("a descriptor handoff").fd_env_value()),
13002            "a subc-wire spawn names the pipe it will receive at descriptor 3"
13003        );
13004        #[cfg(windows)]
13005        assert!(wire_handoff.is_some() && wire_fd_env.is_none());
13006        #[cfg(not(any(unix, windows)))]
13007        assert!(wire_handoff.is_none() && wire_fd_env.is_none());
13008        let wire_args: Vec<String> = wire
13009            .as_std()
13010            .get_args()
13011            .map(|a| a.to_string_lossy().into_owned())
13012            .collect();
13013        assert_eq!(
13014            wire_args,
13015            vec![
13016                SUBC_ARG.to_string(),
13017                connection_file.to_string_lossy().into_owned()
13018            ],
13019            "a subc-wire spawn still carries --subc <path>"
13020        );
13021        assert_eq!(
13022            wire.as_std()
13023                .get_envs()
13024                .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some()),
13025            !cfg!(unix),
13026            "only Windows supplies the environment nonce"
13027        );
13028        assert!(handle.spawn_nonce(&wire_spec.module_id).is_some());
13029    }
13030
13031    /// A plain spawn EXPLICITLY REMOVES the spawn role, even when the module's
13032    /// spec tries to set it; only a swap candidate carries it.
13033    ///
13034    /// "Set it only on candidates" is not enough, because spawn applies the
13035    /// spec's env verbatim and the daemon's own environment is inherited: either
13036    /// could hand a plain restart the swap role, and a module reading it would
13037    /// warm on its long swap budget while callers wait. Asserted as an explicit
13038    /// removal (`(key, None)`), not mere absence, for the reason the `CK_LOG`
13039    /// test above gives.
13040    #[test]
13041    fn plain_spawn_removes_the_spawn_role_even_when_the_spec_sets_it() {
13042        let role = |command: &Command| {
13043            command
13044                .as_std()
13045                .get_envs()
13046                .filter(|(key, _)| *key == OsStr::new(SUBC_SPAWN_ROLE_ENV))
13047                .last()
13048                .map(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()))
13049        };
13050        let forged = spec(vec![(
13051            SUBC_SPAWN_ROLE_ENV.to_string(),
13052            SPAWN_ROLE_SWAP_CANDIDATE.to_string(),
13053        )]);
13054
13055        let mut plain = Command::new("/nonexistent");
13056        apply_child_env(&mut plain, &forged);
13057        apply_spawn_role(&mut plain, SpawnRole::Plain);
13058        assert_eq!(
13059            role(&plain),
13060            Some(None),
13061            "a plain spawn must remove SUBC_SPAWN_ROLE, whatever the spec says"
13062        );
13063
13064        let mut candidate = Command::new("/nonexistent");
13065        apply_child_env(&mut candidate, &spec(Vec::new()));
13066        apply_spawn_role(&mut candidate, SpawnRole::SwapCandidate);
13067        assert_eq!(
13068            role(&candidate),
13069            Some(Some(SPAWN_ROLE_SWAP_CANDIDATE.to_string()))
13070        );
13071    }
13072
13073    /// Daemon-private capture retention keys never reach the child.
13074    ///
13075    /// cortexkit-log exposes retention as a Rust struct with no environment
13076    /// names, so these entries are supervisor metadata. Passing them through
13077    /// would invent a public child-process contract by accident.
13078    #[test]
13079    fn daemon_private_capture_keys_are_not_passed_to_the_child() {
13080        let mut command = Command::new("/nonexistent");
13081        apply_child_env(
13082            &mut command,
13083            &spec(vec![
13084                (super::CAPTURE_KEEP_ENV.to_string(), "5".to_string()),
13085                ("KEPT".to_string(), "yes".to_string()),
13086            ]),
13087        );
13088        let keys: Vec<String> = command
13089            .as_std()
13090            .get_envs()
13091            .filter(|(_, value)| value.is_some())
13092            .map(|(key, _)| key.to_string_lossy().into_owned())
13093            .collect();
13094        assert!(keys.contains(&"KEPT".to_string()), "got {keys:?}");
13095        assert!(
13096            !keys.contains(&super::CAPTURE_KEEP_ENV.to_string()),
13097            "daemon-private capture key leaked to the child: {keys:?}"
13098        );
13099    }
13100}
13101
13102#[cfg(test)]
13103mod jitter_tests {
13104    use super::jittered_health_delay;
13105    use std::{collections::HashSet, time::Duration};
13106
13107    /// Module ids drawn from a real fleet, so the dispersal claim is about names
13108    /// that actually occur rather than invented ones.
13109    ///
13110    /// This is a SAMPLE, not a registry: the property under test is that distinct
13111    /// ids disperse, which holds for any set of distinct strings. Several entries
13112    /// are already historical (modules get renamed), and that costs nothing here --
13113    /// but it means a reader must not mistake this for the live module set, and a
13114    /// rename sweep will match it without there being anything to change.
13115    const FLEET: [&str; 14] = [
13116        "aft",
13117        "alfonso-core",
13118        "magic-context",
13119        "broca",
13120        "thalamus",
13121        "quota",
13122        "engram",
13123        "plexus",
13124        "cerebellum",
13125        "astrocyte",
13126        "synapse",
13127        "subc-mcp",
13128        "cortexkit-credentials",
13129        "subc-federation",
13130    ];
13131
13132    /// Probes must not converge after a fleet-wide restart.
13133    ///
13134    /// This is the property the jitter exists for: every module reconnects at
13135    /// once, and without dispersal all fourteen would then probe on the same
13136    /// tick forever. Nothing failed visibly when this went untested -- a
13137    /// convergent fleet still probes correctly, just in a burst, so the symptom
13138    /// is a periodic load spike that looks like whatever else is running.
13139    #[test]
13140    fn probe_delays_disperse_across_the_fleet() {
13141        let cadence = Duration::from_secs(30);
13142        let delays: HashSet<Duration> = FLEET
13143            .iter()
13144            .map(|id| jittered_health_delay(id, 0, cadence))
13145            .collect();
13146        assert_eq!(
13147            delays.len(),
13148            FLEET.len(),
13149            "every supervised module must land on its own probe offset"
13150        );
13151    }
13152
13153    /// The offset may only ever DELAY a probe, never bring it forward.
13154    ///
13155    /// A delay below the cadence would probe a module more often than
13156    /// configured, which is the opposite of what an operator asked for and
13157    /// would tighten the failure budget without anyone changing it.
13158    #[test]
13159    fn jitter_only_delays_and_stays_within_one_tenth_of_cadence() {
13160        let cadence = Duration::from_secs(30);
13161        let span = cadence / 10;
13162        for id in FLEET {
13163            for probe_index in 0..8 {
13164                let delay = jittered_health_delay(id, probe_index, cadence);
13165                assert!(
13166                    delay >= cadence,
13167                    "{id}#{probe_index}: jitter must not shorten the cadence"
13168                );
13169                assert!(
13170                    delay < cadence + span,
13171                    "{id}#{probe_index}: jitter must stay inside one tenth of the cadence"
13172                );
13173            }
13174        }
13175    }
13176
13177    /// A module keeps its offset across daemon restarts.
13178    ///
13179    /// The delay is derived rather than randomised precisely so a restart does
13180    /// not re-roll every module into a fresh chance of collision. A random
13181    /// source would satisfy the dispersal test above and quietly lose this.
13182    #[test]
13183    fn a_module_offset_is_stable_across_restarts() {
13184        let cadence = Duration::from_secs(30);
13185        for id in FLEET {
13186            assert_eq!(
13187                jittered_health_delay(id, 0, cadence),
13188                jittered_health_delay(id, 0, cadence),
13189                "{id}: the same module and probe index must produce the same offset"
13190            );
13191        }
13192    }
13193
13194    /// A zero cadence disables probing rather than producing a busy loop.
13195    #[test]
13196    fn zero_cadence_yields_zero_delay() {
13197        assert_eq!(
13198            jittered_health_delay("aft", 0, Duration::ZERO),
13199            Duration::ZERO
13200        );
13201    }
13202}
13203
13204#[cfg(all(test, target_os = "linux"))]
13205mod cgroup_placement_tests {
13206    use super::{
13207        apply_cgroup_placement, remove_module_cgroup, ModuleProtocol, ModuleSpec, SuperviseError,
13208        SupervisedChild,
13209    };
13210    use crate::stderr_tail::{StderrRing, StderrTailConfig};
13211    use cortexkit_test_support::ScratchDir;
13212    use std::{
13213        fs, io,
13214        path::{Path, PathBuf},
13215        sync::{Arc, Mutex},
13216    };
13217    use tokio::process::Command;
13218
13219    #[tokio::test]
13220    async fn unique_spawn_cgroups_clean_up_across_restarts_and_shutdown() {
13221        use super::*;
13222        let dir = ScratchDir::new("unique-spawn-cgroups");
13223        let root = PathBuf::from(format!(
13224            "/sys/fs/cgroup/subc-unique-test-{}-{}",
13225            std::process::id(),
13226            unix_ms_now()
13227        ));
13228        if let Err(error) = fs::create_dir(&root) {
13229            assert!(
13230                std::env::var_os("SUBC_REQUIRE_CGROUP_TEST").is_none(),
13231                "required cgroup test cannot execute: {error}"
13232            );
13233            eprintln!("SKIP unique_spawn_cgroups_clean_up_across_restarts_and_shutdown: {error}");
13234            return;
13235        }
13236        let placement = subc_cgroup::prepare_at(&root).unwrap().unwrap();
13237        let group_count = || {
13238            fs::read_dir(root.join("subc-modules"))
13239                .unwrap()
13240                .map(|entry| entry.unwrap().file_type().unwrap())
13241                .filter(|kind| kind.is_dir())
13242                .count()
13243        };
13244        let supervisor =
13245            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
13246                .with_cgroup_placement(Some(placement.clone()));
13247        let runtime = supervisor.runtime_config();
13248        let mut spec = ModuleSpec {
13249            module_id: "unique-spawn".into(),
13250            program: PathBuf::from("/bin/sleep"),
13251            args: vec!["60".into()],
13252            env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
13253                .into_iter()
13254                .map(|key| (key.into(), dir.to_string_lossy().into_owned()))
13255                .collect(),
13256            reserved: false,
13257            reserved_prefixes: vec![],
13258            protocol: ModuleProtocol::None,
13259            overlap: Default::default(),
13260        };
13261        let spawn = |spec: &ModuleSpec| {
13262            spawn_child(
13263                spec,
13264                None,
13265                None,
13266                &runtime.stderr_ring,
13267                None,
13268                &runtime.child_roster,
13269                Some(&placement),
13270            )
13271            .unwrap()
13272        };
13273        let mut live = spawn(&spec);
13274        for _ in 0..3 {
13275            // A new process can enter the old slot while retirement is pending.
13276            let next = spawn(&spec);
13277            assert_ne!(live.module_id, next.module_id);
13278            live.start_kill().unwrap();
13279            live.wait().await.unwrap();
13280            live = next;
13281            assert!(
13282                live.child.try_wait().unwrap().is_none(),
13283                "retiring the old slot must not kill the replacement"
13284            );
13285            assert_eq!(
13286                group_count(),
13287                1,
13288                "only the live spawn's cgroup should remain"
13289            );
13290        }
13291        supervisor.begin_daemon_shutdown();
13292        let reap = tokio::spawn(async move {
13293            live.wait().await.unwrap();
13294        });
13295        supervisor
13296            .end_children_for_daemon_shutdown(false, std::future::pending())
13297            .await;
13298        reap.await.unwrap();
13299        assert_eq!(group_count(), 0);
13300        // A normal exit uses the same tree-cleanup path as a killed spawn.
13301        spec.program = PathBuf::from("/bin/true");
13302        spec.args.clear();
13303        let fresh_roster = ChildRoster::default();
13304        let mut short = spawn_child(
13305            &spec,
13306            None,
13307            None,
13308            &runtime.stderr_ring,
13309            None,
13310            &fresh_roster,
13311            Some(&placement),
13312        )
13313        .unwrap();
13314        short.wait().await.unwrap();
13315        assert_eq!(group_count(), 0);
13316        spec.module_id = "_".repeat(255);
13317        let mut long_id = spawn_child(
13318            &spec,
13319            None,
13320            None,
13321            &runtime.stderr_ring,
13322            None,
13323            &fresh_roster,
13324            Some(&placement),
13325        )
13326        .unwrap();
13327        long_id.wait().await.unwrap();
13328        assert_eq!(
13329            group_count(),
13330            0,
13331            "valid long module IDs must not exceed cgroup NAME_MAX"
13332        );
13333        fs::remove_dir(root.join("subc-modules")).unwrap();
13334        fs::remove_dir(root).unwrap();
13335        eprintln!("EXECUTED unique_spawn_cgroups_clean_up_across_restarts_and_shutdown");
13336    }
13337
13338    #[test]
13339    fn failed_parent_cgroup_open_is_a_cgroup_supervision_error() {
13340        let path = Path::new("/definitely-missing-subc-cgroup");
13341        let mut command = Command::new("true");
13342        let error = apply_cgroup_placement(
13343            &mut command,
13344            &ModuleSpec {
13345                module_id: "broken-cgroup".to_string(),
13346                program: PathBuf::from("true"),
13347                args: Vec::new(),
13348                env: Vec::new(),
13349                reserved: false,
13350                reserved_prefixes: Vec::new(),
13351                protocol: ModuleProtocol::Subc,
13352                overlap: Default::default(),
13353            },
13354            path,
13355        )
13356        .expect_err("a parent cgroup open failure must reject the supervised spawn");
13357        let reason = error.to_string();
13358
13359        assert!(
13360            matches!(error, SuperviseError::Cgroup { .. }),
13361            "parent cgroup open must be reported as a cgroup supervision error: {reason}"
13362        );
13363        assert!(
13364            reason.contains("/definitely-missing-subc-cgroup/cgroup.procs"),
13365            "parent cgroup open failure must name cgroup.procs: {reason}"
13366        );
13367    }
13368
13369    #[tokio::test]
13370    async fn reaping_a_child_removes_its_empty_module_cgroup() {
13371        let root = ScratchDir::new("supervisor-reap-cgroup");
13372        fs::write(root.join("cgroup.procs"), b"").expect("write scratch cgroup marker");
13373        let placement = subc_cgroup::prepare_at(&root)
13374            .expect("prepare scratch cgroup root")
13375            .expect("scratch root has a cgroup.procs marker");
13376        let module_id = "reaped-module";
13377        let module = placement
13378            .module_path(module_id)
13379            .expect("create scratch module cgroup");
13380        let child = Command::new("true")
13381            .env("XDG_DATA_HOME", root.path())
13382            .env("XDG_RUNTIME_DIR", root.path())
13383            .env("XDG_CONFIG_HOME", root.path())
13384            .spawn()
13385            .expect("spawn short-lived child");
13386        let pid = child.id().expect("spawned child has pid");
13387        let mut child = SupervisedChild {
13388            child,
13389            protocol: ModuleProtocol::Subc,
13390            module_id: module_id.to_string(),
13391            cgroup_placement: Some(placement),
13392            stdout_pump: None,
13393            stderr_pump: None,
13394            stderr_ring: Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default()))),
13395            spawned_at_ms: 0,
13396            spawned_from: PathBuf::from("true"),
13397            spawned_file_identity: None,
13398            process_start_time: None,
13399            process_identity: None,
13400            pid,
13401            roster_guard: None,
13402            #[cfg(target_os = "macos")]
13403            privacy_exec: None,
13404            spawn_failure: None,
13405        };
13406
13407        child.wait().await.expect("reap short-lived child");
13408
13409        assert!(
13410            !module.exists(),
13411            "reaping the supervised child must remove its empty cgroup"
13412        );
13413    }
13414
13415    #[test]
13416    fn non_empty_cgroup_removal_is_reported_without_blocking_teardown() {
13417        let root = ScratchDir::new("supervisor-non-empty-cgroup");
13418        fs::write(root.join("cgroup.procs"), b"").expect("write scratch cgroup marker");
13419        let placement = subc_cgroup::prepare_at(&root)
13420            .expect("prepare scratch cgroup root")
13421            .expect("scratch root has a cgroup.procs marker");
13422        let module = placement
13423            .module_path("surviving-module")
13424            .expect("create scratch module cgroup");
13425        fs::write(module.join("surviving-process"), b"still present")
13426            .expect("make scratch cgroup non-empty");
13427        let (logs, _guard) = crate::router::test_log::log_capture(tracing::Level::WARN);
13428
13429        remove_module_cgroup(&placement, "surviving-module");
13430
13431        let logs = crate::router::test_log::captured_logs(&logs);
13432        assert!(
13433            module.exists(),
13434            "failed removal must leave the cgroup intact"
13435        );
13436        assert!(
13437            logs.contains("could not remove module cgroup after process exit; continuing teardown")
13438                && logs.contains("surviving-module"),
13439            "best-effort removal must report the failure without returning it: {logs}"
13440        );
13441    }
13442
13443    #[test]
13444    fn cgroup_pre_exec_spawn_failure_names_the_cgroup_path() {
13445        let cgroup_path = PathBuf::from("/sys/fs/cgroup/subc-modules/broken-module");
13446        let reason = SuperviseError::Spawn {
13447            program: PathBuf::from("/bin/true"),
13448            source: io::Error::from_raw_os_error(13),
13449            cgroup_path: Some(cgroup_path.clone()),
13450        }
13451        .to_string();
13452
13453        assert!(
13454            reason.contains(&cgroup_path.display().to_string()),
13455            "a pre_exec spawn failure must name the cgroup path: {reason}"
13456        );
13457    }
13458}
13459
13460#[cfg(test)]
13461mod spawn_subscriber_lag_tests {
13462    use super::*;
13463
13464    /// A subscriber whose connection stops draining is dropped once its frame
13465    /// channel fills. The client must learn that from a terminal Error frame
13466    /// after the frames already queued for it, not from a stream that simply
13467    /// goes quiet.
13468    #[tokio::test]
13469    async fn lagged_spawn_subscriber_receives_a_terminal_lagged_error_after_its_queued_frames() {
13470        let feed = SpawnEventFeed::default();
13471        feed.configure_incarnation("lag-incarnation".to_string());
13472        // A one-slot connection queue that nobody reads until the emits are
13473        // done: the forwarder parks on it and the subscriber channel fills.
13474        let (tx, mut rx) = mpsc::channel(1);
13475        feed.subscribe(ConnectionId::new(1), 7, 1, None, FrameSink::new(tx))
13476            .expect("subscribe");
13477        let emitted = SPAWN_SUBSCRIBER_BUFFER + 16;
13478        for index in 0..emitted {
13479            feed.emit_spawned(&format!("lag-module-{index}"), 1000, 0);
13480            // Let the forwarder take what it can so the fill point is the
13481            // subscriber channel, not a scheduling accident.
13482            tokio::task::yield_now().await;
13483        }
13484        assert_eq!(
13485            feed.subscriber_count(),
13486            0,
13487            "the lagged subscriber must be removed"
13488        );
13489
13490        let mut data = Vec::new();
13491        let mut last = None;
13492        loop {
13493            let next = tokio::time::timeout(Duration::from_secs(5), rx.recv())
13494                .await
13495                .expect("the forwarder must finish once the subscriber is dropped");
13496            let Some(outbound) = next else { break };
13497            let frame = outbound.frame;
13498            if frame.header.ty == FrameType::StreamData {
13499                assert!(last.is_none(), "no data may follow the terminal frame");
13500                let event: SpawnEvent = serde_json::from_slice(&frame.body).unwrap();
13501                data.push(event.cursor.seq);
13502            } else {
13503                assert!(last.is_none(), "exactly one terminal frame");
13504                last = Some(frame);
13505            }
13506        }
13507        assert!(!data.is_empty(), "queued frames drain before the terminal");
13508        for pair in data.windows(2) {
13509            assert_eq!(
13510                pair[1],
13511                pair[0] + 1,
13512                "queued frames arrive dense and in order"
13513            );
13514        }
13515        let terminal = last.expect("a lagged subscriber must receive a terminal frame");
13516        assert_eq!(terminal.header.ty, FrameType::Error);
13517        assert_eq!(terminal.header.corr, 7);
13518        let body: subc_protocol::ErrorBody = serde_json::from_slice(&terminal.body).unwrap();
13519        assert_eq!(body.code, SPAWN_SUBSCRIBER_LAGGED_CODE);
13520        let detail = body.detail.expect("lagged error carries detail");
13521        assert_eq!(
13522            detail["first_undelivered_cursor"]["seq"],
13523            data.last().unwrap() + 1,
13524            "the named cursor is the first event the subscriber did not receive"
13525        );
13526        assert_eq!(
13527            detail["first_undelivered_cursor"]["daemon_incarnation"],
13528            "lag-incarnation"
13529        );
13530    }
13531}
13532
13533#[cfg(test)]
13534mod terminal_history_read_concurrency_tests {
13535    use super::*;
13536    use crate::terminal_journal::read_pause;
13537    use cortexkit_test_support::ScratchDir;
13538    use std::sync::mpsc as std_mpsc;
13539
13540    fn journaled_ring(
13541        journal: &Arc<crate::terminal_journal::TerminalJournal>,
13542    ) -> Arc<Mutex<TerminalRing>> {
13543        Arc::new(Mutex::new(
13544            TerminalRing::new(TerminalRingConfig::default(), 1)
13545                .with_journal(Some(Arc::clone(journal))),
13546        ))
13547    }
13548
13549    fn crash(at_ms: u64) -> ExitReport {
13550        ExitReport {
13551            kind: ExitKind::Crash,
13552            code: Some(1),
13553            signal: None,
13554            at_ms,
13555        }
13556    }
13557
13558    /// Record an exit on another thread and report whether it finished within
13559    /// `bound`. The recorder thread is left running if it did not.
13560    fn record_within(
13561        module_id: &'static str,
13562        ring: &Arc<Mutex<TerminalRing>>,
13563        at_ms: u64,
13564        bound: Duration,
13565    ) -> bool {
13566        let ring = Arc::clone(ring);
13567        let (done, done_rx) = std_mpsc::channel();
13568        std::thread::spawn(move || {
13569            record_terminal(
13570                module_id,
13571                &ring,
13572                &SpawnEventFeed::default(),
13573                &crash(at_ms),
13574                TerminalDisposition::Restarting,
13575            );
13576            let _ = done.send(());
13577        });
13578        done_rx.recv_timeout(bound).is_ok()
13579    }
13580
13581    /// A history read in progress must not hold the journal writer (which every
13582    /// module's exit recording needs) or the module's own ring. Exits recorded
13583    /// while the read is paused complete promptly; the paused read answers as of
13584    /// the moment it started, and the next read has each exit exactly once.
13585    #[test]
13586    fn exits_recorded_during_a_paused_history_read_are_not_blocked_or_half_merged() {
13587        let dir = ScratchDir::new("terminal-history-concurrent-read");
13588        let path = dir.join("terminals.jsonl");
13589        let journal = Arc::new(crate::terminal_journal::TerminalJournal::open(
13590            path.clone(),
13591            "daemon".into(),
13592        ));
13593        let reader_ring = journaled_ring(&journal);
13594        let other_ring = journaled_ring(&journal);
13595        assert!(record_within(
13596            "reader-module",
13597            &reader_ring,
13598            10,
13599            Duration::from_secs(5)
13600        ));
13601
13602        let (started, release) = read_pause::install(&path);
13603        let reading = {
13604            let ring = Arc::clone(&reader_ring);
13605            std::thread::spawn(move || durable_terminal_history_of(&ring, "reader-module"))
13606        };
13607        started
13608            .recv_timeout(Duration::from_secs(5))
13609            .expect("the history read reached its pause");
13610
13611        let bound = Duration::from_secs(1);
13612        assert!(
13613            record_within("other-module", &other_ring, 20, bound),
13614            "another module's exit waited on a history read (journal writer held)"
13615        );
13616        assert!(
13617            record_within("reader-module", &reader_ring, 30, bound),
13618            "the read module's own exit waited on its history read (ring held)"
13619        );
13620
13621        drop(release);
13622        let paused = reading.join().unwrap();
13623        assert_eq!(
13624            paused.entries.iter().map(|e| e.at_ms).collect::<Vec<_>>(),
13625            vec![10],
13626            "an exit recorded after the read began lands in neither half of it"
13627        );
13628        assert_eq!(paused.journal_skipped_lines, 0);
13629        assert_eq!(paused.journal_read_errors, 0);
13630
13631        let after = durable_terminal_history_of(&reader_ring, "reader-module");
13632        assert_eq!(
13633            after.entries.iter().map(|e| e.at_ms).collect::<Vec<_>>(),
13634            vec![10, 30],
13635            "the next read merges ring and journal with no duplicate"
13636        );
13637        assert_eq!(after.journal_skipped_lines, 0);
13638    }
13639}
13640
13641/// What a restart does with the exited process's output readers. These drive
13642/// the same settle functions the supervisor calls, with a reader the test
13643/// holds, so a reader that has not been scheduled by the bound is a controlled
13644/// input rather than something only a loaded machine produces.
13645#[cfg(test)]
13646mod stderr_settle_tests {
13647    use std::{
13648        future::Future,
13649        io,
13650        pin::Pin,
13651        sync::{Arc, Mutex},
13652        task::{Context, Poll},
13653        time::Duration,
13654    };
13655
13656    use tokio::{
13657        io::{AsyncRead, ReadBuf},
13658        sync::oneshot,
13659        time::Instant,
13660    };
13661
13662    use super::{settle_stderr_pump, settle_stdout_pump, StderrPump};
13663    use crate::stderr_tail::{
13664        pump_stderr_to, pump_stdout_to, split_capture_stamp, untimed, CaptureState,
13665        ChildOutputSink, OutputSink, StderrRing, StderrTailConfig, TailEntry,
13666    };
13667
13668    const BOUND: Duration = Duration::from_millis(250);
13669
13670    /// Yields `before`, then stays pending until the gate is released, then
13671    /// yields `after` and reaches EOF. The bytes after the gate were written
13672    /// by a process that has already exited; only the reader is behind.
13673    struct HeldReader {
13674        before: Option<Vec<u8>>,
13675        gate: Option<oneshot::Receiver<()>>,
13676        after: io::Cursor<Vec<u8>>,
13677    }
13678
13679    impl AsyncRead for HeldReader {
13680        fn poll_read(
13681            mut self: Pin<&mut Self>,
13682            cx: &mut Context<'_>,
13683            buf: &mut ReadBuf<'_>,
13684        ) -> Poll<io::Result<()>> {
13685            if let Some(bytes) = self.before.take() {
13686                buf.put_slice(&bytes);
13687                return Poll::Ready(Ok(()));
13688            }
13689            if let Some(gate) = self.gate.as_mut() {
13690                match Pin::new(gate).poll(cx) {
13691                    Poll::Pending => return Poll::Pending,
13692                    Poll::Ready(_) => self.gate = None,
13693                }
13694            }
13695            Pin::new(&mut self.after).poll_read(cx, buf)
13696        }
13697    }
13698
13699    struct DiscardSink;
13700
13701    impl OutputSink for DiscardSink {
13702        fn write_line(&mut self, _line: &[u8]) {}
13703    }
13704
13705    fn line(text: &str) -> TailEntry {
13706        TailEntry::Line {
13707            text: text.to_string(),
13708            truncated: false,
13709            at_ms: None,
13710        }
13711    }
13712
13713    fn lock(ring: &Arc<Mutex<StderrRing>>) -> std::sync::MutexGuard<'_, StderrRing> {
13714        ring.lock().unwrap()
13715    }
13716
13717    /// Start a reader for a new process generation that delivers `before`
13718    /// immediately and `after` only once the returned sender fires (or is
13719    /// dropped).
13720    fn held_pump(
13721        ring: &Arc<Mutex<StderrRing>>,
13722        before: &str,
13723        after: &str,
13724    ) -> (StderrPump, oneshot::Sender<()>) {
13725        let generation = lock(ring).begin_process();
13726        let (release, gate) = oneshot::channel();
13727        let reader = HeldReader {
13728            before: Some(before.as_bytes().to_vec()),
13729            gate: Some(gate),
13730            after: io::Cursor::new(after.as_bytes().to_vec()),
13731        };
13732        let task = tokio::spawn(pump_stderr_to(
13733            reader,
13734            Arc::clone(ring),
13735            generation,
13736            DiscardSink,
13737        ));
13738        (StderrPump { task, generation }, release)
13739    }
13740
13741    async fn wait_until(ring: &Arc<Mutex<StderrRing>>, done: impl Fn(&StderrRing) -> bool) {
13742        for _ in 0..1000 {
13743            if done(&lock(ring)) {
13744                return;
13745            }
13746            tokio::time::sleep(Duration::from_millis(1)).await;
13747        }
13748        panic!(
13749            "ring never reached the expected state: {:?}",
13750            lock(ring).snapshot(None, None)
13751        );
13752    }
13753
13754    #[tokio::test(start_paused = true)]
13755    async fn a_crash_line_the_reader_had_not_reached_by_the_bound_is_kept_before_the_restart() {
13756        let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
13757        let (pump, release) = held_pump(&ring, "booting\n", "config error: missing storage\n");
13758
13759        settle_stderr_pump("crasher", &ring, pump, BOUND).await;
13760        let before_release = lock(&ring).snapshot(None, None);
13761        assert!(
13762            matches!(before_release.capture, CaptureState::Incomplete { .. }),
13763            "a reader that has not reached EOF cannot claim a whole tail: {before_release:?}"
13764        );
13765
13766        // The restart: the next process starts and writes before the old
13767        // reader catches up.
13768        let next = lock(&ring).begin_process();
13769        lock(&ring).push_line_from(next, "next process booting");
13770        release.send(()).unwrap();
13771        wait_until(&ring, |ring| {
13772            ring.snapshot(None, None).capture == CaptureState::Captured
13773        })
13774        .await;
13775
13776        assert_eq!(
13777            untimed(lock(&ring).snapshot(None, None).entries),
13778            vec![
13779                line("booting"),
13780                line("config error: missing storage"),
13781                TailEntry::ProcessStart,
13782                line("next process booting"),
13783            ],
13784            "the crash's last line must survive a slow reader and stay in the crashed process's section"
13785        );
13786    }
13787
13788    #[tokio::test(start_paused = true)]
13789    async fn a_pipe_held_open_by_a_descendant_reads_incomplete_without_delaying_the_restart_past_the_bound(
13790    ) {
13791        let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
13792        // `_held` is never fired: a descendant keeps the pipe open for the
13793        // whole test.
13794        let (pump, _held) = held_pump(&ring, "parent exiting\n", "");
13795
13796        let started = Instant::now();
13797        settle_stderr_pump("orphaning", &ring, pump, BOUND).await;
13798        assert_eq!(
13799            started.elapsed(),
13800            BOUND,
13801            "the restart must wait exactly the bound for a pipe that stays open, no longer"
13802        );
13803
13804        let next = lock(&ring).begin_process();
13805        lock(&ring).push_line_from(next, "next process booting");
13806        tokio::time::sleep(Duration::from_secs(60)).await;
13807
13808        let snapshot = lock(&ring).snapshot(None, None);
13809        match &snapshot.capture {
13810            CaptureState::Incomplete { reason } => assert!(
13811                reason.contains("had not reached EOF") && reason.contains("250ms"),
13812                "the reason must say what is missing and after how long: {reason}"
13813            ),
13814            other => panic!("expected Incomplete while the pipe is held open, got {other:?}"),
13815        }
13816        assert_eq!(
13817            untimed(snapshot.entries),
13818            vec![
13819                line("parent exiting"),
13820                TailEntry::ProcessStart,
13821                line("next process booting"),
13822            ]
13823        );
13824    }
13825
13826    #[tokio::test(start_paused = true)]
13827    async fn a_reader_that_reaches_eof_within_the_bound_leaves_the_tail_captured() {
13828        let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
13829        let (pump, release) = held_pump(&ring, "one\n", "two\n");
13830        release.send(()).unwrap();
13831
13832        settle_stderr_pump("clean", &ring, pump, BOUND).await;
13833
13834        let snapshot = lock(&ring).snapshot(None, None);
13835        assert_eq!(snapshot.capture, CaptureState::Captured);
13836        assert_eq!(untimed(snapshot.entries), vec![line("one"), line("two")]);
13837    }
13838
13839    #[tokio::test(start_paused = true)]
13840    async fn stdout_buffered_past_the_exit_drain_bound_reaches_the_capture_file() {
13841        const BURST: usize = 150;
13842        let capture = cortexkit_test_support::ScratchDir::new("stdout-exit-drain");
13843        let path = capture.join("two-pipe.stderr.log");
13844        let sink = ChildOutputSink::open(&path, cortexkit_log::Retention::default()).unwrap();
13845        let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
13846        let generation = lock(&ring).begin_process();
13847        let stdout = (0..BURST)
13848            .map(|i| format!("out-{i:04}-{}\n", "o".repeat(64)))
13849            .collect::<String>();
13850        let stderr = (0..BURST)
13851            .map(|i| format!("err-{i:04}-{}\n", "e".repeat(64)))
13852            .collect::<String>();
13853        // Model a child that has written both bursts and exited. Hold the stdout
13854        // reader after one line and a partial prefix, as if pipe readiness or
13855        // capture-file writes had delayed it. The final line also needs EOF to
13856        // flush: neither complete nor partial buffered output may be discarded.
13857        let first_chunk = stdout.find('\n').unwrap() + 1 + "out-0001-".len();
13858        let (release, gate) = oneshot::channel();
13859        let reader = HeldReader {
13860            before: Some(stdout.as_bytes()[..first_chunk].to_vec()),
13861            gate: Some(gate),
13862            after: io::Cursor::new(stdout.as_bytes()[first_chunk..stdout.len() - 1].to_vec()),
13863        };
13864        let (finished, completion) = oneshot::channel();
13865        let stdout_sink = sink.clone();
13866        let pump = tokio::spawn(async move {
13867            pump_stdout_to(reader, stdout_sink).await;
13868            finished.send(()).unwrap();
13869        });
13870        pump_stderr_to(io::Cursor::new(stderr.into_bytes()), ring, generation, sink).await;
13871
13872        let started = Instant::now();
13873        settle_stdout_pump("two-pipe", pump, BOUND).await;
13874        assert_eq!(
13875            started.elapsed(),
13876            BOUND,
13877            "a late reader must not hold up restart"
13878        );
13879        assert_eq!(
13880            std::fs::read_to_string(&path).unwrap().lines().count(),
13881            BURST + 1
13882        );
13883
13884        release
13885            .send(())
13886            .expect("stdout reader must survive the exit drain bound");
13887        tokio::time::timeout(Duration::from_secs(1), completion)
13888            .await
13889            .expect("released stdout reader must reach EOF")
13890            .expect("stdout capture must finish at EOF, not be cancelled");
13891        let contents = std::fs::read_to_string(&path).unwrap();
13892        let mut lines = contents
13893            .lines()
13894            .map(|line| split_capture_stamp(line).expect("capture stamp").1)
13895            .collect::<Vec<_>>();
13896        let mut expected = (0..BURST)
13897            .flat_map(|i| {
13898                [
13899                    format!("out-{i:04}-{}", "o".repeat(64)),
13900                    format!("err-{i:04}-{}", "e".repeat(64)),
13901                ]
13902            })
13903            .collect::<Vec<_>>();
13904        lines.sort_unstable();
13905        expected.sort_unstable();
13906        assert_eq!(
13907            lines, expected,
13908            "both pipes must retain every complete line exactly once"
13909        );
13910    }
13911}
13912
13913/// Containment of a module's process tree (issue #109).
13914///
13915/// The behaviour these defend against is a module helper surviving its module:
13916/// on a real machine the Synapse embedding module's CUDA worker holds ~2.2 GB of
13917/// VRAM, so a leaked grandchild is a leaked GPU allocation, and a day of restarts
13918/// compounds it.
13919///
13920/// They run against the SUPERVISOR rather than the job-object crate because the
13921/// claim is about teardown: a crate-level test proves a job can reap a tree, not
13922/// that the daemon's drain path reaches it.
13923///
13924/// Windows-only, like the mechanism. On Unix this arm compiles out; the cgroup
13925/// lane there is a separate containment path with its own tests.
13926#[cfg(all(test, windows))]
13927mod job_containment_tests {
13928    use super::*;
13929    use cortexkit_test_support::ScratchDir;
13930    use std::{
13931        path::{Path, PathBuf},
13932        sync::{Arc, Mutex},
13933        time::{Duration, Instant},
13934    };
13935
13936    /// The stub, expected beside this test executable.
13937    ///
13938    /// The existence check is here for the reason its twin at `fake_aft_stub_path`
13939    /// documents: `--lib` does not build `[[bin]]` targets, and a bare spawn
13940    /// failure then reads as a broken test rather than an unbuilt dependency.
13941    fn stub_path() -> PathBuf {
13942        let mut path = std::env::current_exe().expect("current_exe available in tests");
13943        path.pop();
13944        path.pop();
13945        path.push("fake-aft-stub.exe");
13946        assert!(
13947            path.exists(),
13948            "fake-aft-stub not built at {}: run `cargo test -p subc-core` (which builds \
13949             [[bin]] targets) rather than `cargo test -p subc-core --lib` (which does not)",
13950            path.display()
13951        );
13952        path
13953    }
13954
13955    /// Poll for the grandchild pid the stub records, and parse it.
13956    fn read_grandchild_pid(path: &Path) -> u32 {
13957        let deadline = Instant::now() + Duration::from_secs(10);
13958        loop {
13959            if let Ok(contents) = std::fs::read_to_string(path) {
13960                if let Ok(pid) = contents.trim().parse() {
13961                    return pid;
13962                }
13963            }
13964            assert!(
13965                Instant::now() < deadline,
13966                "the stub never recorded a grandchild pid at {}",
13967                path.display()
13968            );
13969            std::thread::sleep(Duration::from_millis(10));
13970        }
13971    }
13972
13973    /// Everything one fixture run needs, so the two tests below differ in exactly
13974    /// one place: whether the child is contained.
13975    struct Fixture {
13976        _dir: ScratchDir,
13977        module_id: String,
13978        grandchild: u32,
13979        child: Option<SupervisedChild>,
13980        registry: Arc<Registry>,
13981        snapshot: Arc<Mutex<SupervisorSnapshot>>,
13982        terminal_ring: Arc<Mutex<TerminalRing>>,
13983        spawn_events: SpawnEventFeed,
13984    }
13985
13986    fn fixture(label: &str, module_id: &str) -> Fixture {
13987        let dir = ScratchDir::new(label);
13988        let pid_file = dir.join("grandchild.pid");
13989        let supervisor = Supervisor::new_for_test(
13990            Arc::new(Registry::default()),
13991            RestartPolicy::new(3, Duration::ZERO),
13992        );
13993        let runtime = supervisor.runtime_config();
13994        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
13995        let spec = ModuleSpec {
13996            module_id: module_id.to_string(),
13997            program: stub_path(),
13998            // Zero args deliberately: a `--subc` argument would make the stub dial
13999            // a daemon that is not there, and the failure would land in the same
14000            // stderr ring this fixture exists to keep quiet.
14001            args: Vec::new(),
14002            env: vec![
14003                ("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string()),
14004                (
14005                    "FAKE_AFT_GRANDCHILD_PID_FILE".to_string(),
14006                    pid_file.display().to_string(),
14007                ),
14008            ],
14009            reserved: false,
14010            reserved_prefixes: Vec::new(),
14011            protocol: ModuleProtocol::Subc,
14012            overlap: Default::default(),
14013        };
14014        let child = spawn_and_mark_running(&spec, &runtime, &snapshot)
14015            .expect("spawn the supervised fixture");
14016        let grandchild = read_grandchild_pid(&pid_file);
14017        Fixture {
14018            _dir: dir,
14019            module_id: module_id.to_string(),
14020            grandchild,
14021            child: Some(child),
14022            registry: Arc::new(Registry::default()),
14023            snapshot,
14024            terminal_ring: Arc::clone(&runtime.terminal_ring),
14025            spawn_events: SpawnEventFeed::default(),
14026        }
14027    }
14028
14029    impl Fixture {
14030        /// Drain through the supervisor's own teardown path.
14031        async fn drain(&mut self) {
14032            let child = self
14033                .child
14034                .take()
14035                .expect("the fixture child is still present");
14036            drain_child_to_state(
14037                &self.module_id,
14038                ModuleProtocol::Subc,
14039                // No forwarding table in this fixture, so nothing reaches the
14040                // child over a connection.
14041                StopNotice::NotSent,
14042                &self.registry,
14043                None,
14044                &self.snapshot,
14045                &self.terminal_ring,
14046                &self.spawn_events,
14047                child,
14048                Duration::from_millis(500),
14049                ModuleState::Stopped,
14050                Some(false),
14051            )
14052            .await
14053            .expect("drain the supervised fixture");
14054        }
14055    }
14056
14057    /// Teardown reaps the grandchild, not merely the direct child.
14058    ///
14059    /// This is the assertion the change exists for. Before containment the
14060    /// grandchild survived: it is a separate process, and `start_kill` is
14061    /// `TerminateProcess` scoped to one pid.
14062    #[tokio::test]
14063    async fn teardown_reaps_the_grandchild() {
14064        let mut fixture = fixture("teardown-grandchild", "tree-teardown");
14065        let grandchild = fixture.grandchild;
14066
14067        assert!(
14068            subc_jobobject::process_exists(grandchild),
14069            "grandchild {grandchild} must be alive before teardown, or this proves nothing"
14070        );
14071
14072        fixture.drain().await;
14073
14074        assert!(
14075            subc_jobobject::wait_for_process_exit(grandchild, Duration::from_secs(10)),
14076            "grandchild {grandchild} outlived module teardown: the tree was not contained"
14077        );
14078    }
14079
14080    /// The mutation control: with containment withheld, the grandchild survives
14081    /// the same kill.
14082    ///
14083    /// This is the defect reproduction from #109 — a direct-child kill reaches
14084    /// one pid, and the grandchild is a different process. It spawns OUTSIDE the
14085    /// supervisor because `spawn_and_mark_running` now always contains on
14086    /// Windows, which is the point: there is no longer a path that spawns
14087    /// uncontained, so the control has to construct one.
14088    ///
14089    /// Its job is to keep `teardown_reaps_the_grandchild` honest. If the
14090    /// grandchild ever dies here, that test is passing for a reason unrelated to
14091    /// the job object and the containment claim is unproven.
14092    #[test]
14093    fn an_uncontained_grandchild_survives_a_direct_child_kill() {
14094        let dir = ScratchDir::new("teardown-uncontained");
14095        let pid_file = dir.join("grandchild.pid");
14096        let mut child = std::process::Command::new(stub_path())
14097            .env("FAKE_AFT_NEVER_CONNECT", "1")
14098            .env(
14099                "FAKE_AFT_GRANDCHILD_PID_FILE",
14100                pid_file.display().to_string(),
14101            )
14102            .stdin(std::process::Stdio::null())
14103            .stdout(std::process::Stdio::null())
14104            .stderr(std::process::Stdio::null())
14105            .spawn()
14106            .expect("spawn the uncontained fixture");
14107        let grandchild = read_grandchild_pid(&pid_file);
14108
14109        // Exactly what the pre-fix teardown did: kill the direct child.
14110        child.kill().expect("kill the direct child");
14111        let _ = child.wait();
14112
14113        assert!(
14114            subc_jobobject::process_exists(grandchild),
14115            "grandchild {grandchild} died with the direct child, so this control no longer \
14116             distinguishes contained from uncontained teardown and the regression test is \
14117             passing vacuously"
14118        );
14119
14120        // The orphan this control demonstrates is the leak the fix prevents, so
14121        // the control must not leave one behind.
14122        kill_tree(grandchild);
14123    }
14124
14125    /// Crash durability: closing the containment handle reaps the tree with no
14126    /// teardown code running at all.
14127    ///
14128    /// This is the case `taskkill /T` cannot cover — a daemon that dies cannot
14129    /// call anything — and it is why containment is a kernel property of the
14130    /// handle rather than a step in the drain. Discovered by getting the
14131    /// mutation control wrong: clearing `job` to "disable" containment instead
14132    /// killed the tree, which is the guarantee, not a mistake.
14133    #[tokio::test]
14134    async fn dropping_containment_reaps_the_grandchild() {
14135        let mut fixture = fixture("drop-containment", "tree-drop");
14136        let grandchild = fixture.grandchild;
14137
14138        assert!(subc_jobobject::process_exists(grandchild));
14139
14140        // No `drain` call, no kill: dropping the handle is the entire mechanism.
14141        fixture.child.as_mut().expect("child present").job = None;
14142
14143        assert!(
14144            subc_jobobject::wait_for_process_exit(grandchild, Duration::from_secs(10)),
14145            "grandchild {grandchild} survived the containment handle closing, so a daemon \
14146             crash would leave the tree behind"
14147        );
14148    }
14149
14150    /// Kill a pid and its tree, then confirm it is gone.
14151    fn kill_tree(pid: u32) {
14152        let _ = std::process::Command::new("taskkill.exe")
14153            .args(["/PID", &pid.to_string(), "/T", "/F"])
14154            .stdin(std::process::Stdio::null())
14155            .stdout(std::process::Stdio::null())
14156            .stderr(std::process::Stdio::null())
14157            .status();
14158        assert!(
14159            subc_jobobject::wait_for_process_exit(pid, Duration::from_secs(10)),
14160            "could not clean up grandchild {pid}"
14161        );
14162    }
14163}
14164
14165#[cfg(test)]
14166mod privacy_trampoline_configuration_tests {
14167    #[tokio::test]
14168    #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
14169    async fn macos_spawn_without_a_configured_trampoline_refuses_by_name() {
14170        #[cfg(target_os = "macos")]
14171        {
14172            let supervisor = super::Supervisor::new(
14173                std::sync::Arc::new(crate::Registry::default()),
14174                super::RestartPolicy::default(),
14175            );
14176            let error = supervisor.spawn(spec()).unwrap_err();
14177            assert!(
14178                error
14179                    .to_string()
14180                    .contains("no privacy trampoline configured"),
14181                "{error}"
14182            );
14183        }
14184    }
14185
14186    #[tokio::test]
14187    #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
14188    async fn macos_wrong_trampoline_is_rejected_by_the_startup_probe() {
14189        #[cfg(target_os = "macos")]
14190        {
14191            let supervisor = super::Supervisor::new(
14192                std::sync::Arc::new(crate::Registry::default()),
14193                super::RestartPolicy::default(),
14194            )
14195            .with_privacy_trampoline(std::env::current_exe().unwrap());
14196            let error = supervisor.spawn(spec()).unwrap_err();
14197            assert!(
14198                error
14199                    .to_string()
14200                    .contains("binary does not implement the privacy trampoline protocol"),
14201                "{error}"
14202            );
14203        }
14204    }
14205
14206    #[cfg(target_os = "macos")]
14207    fn spec() -> super::ModuleSpec {
14208        super::ModuleSpec {
14209            module_id: "privacy-configuration".into(),
14210            program: "/bin/sleep".into(),
14211            args: vec!["30".into()],
14212            env: vec![],
14213            reserved: false,
14214            reserved_prefixes: vec![],
14215            protocol: subc_control::ModuleProtocol::None,
14216            overlap: super::ModuleOverlap::Exclusive,
14217        }
14218    }
14219}
14220
14221#[cfg(test)]
14222mod privacy_exec_boundary_tests {
14223    #[cfg(target_os = "macos")]
14224    use super::*;
14225    #[cfg(target_os = "macos")]
14226    use std::{
14227        io::{Read, Write},
14228        net::{TcpListener, TcpStream},
14229    };
14230
14231    /// Unit-test-only pause at the actual early image read, not at a later
14232    /// status read. Production supervisors never inspect this environment key.
14233    #[cfg(target_os = "macos")]
14234    pub(super) fn before_image_sample(spec: &ModuleSpec, pid: u32) {
14235        if let Some((_, path)) = spec
14236            .env
14237            .iter()
14238            .find(|(key, _)| key == "SUBC_TEST_PRIVACY_SAMPLE_BARRIER")
14239        {
14240            let mut barrier = TcpStream::connect(path).unwrap();
14241            barrier
14242                .set_read_timeout(Some(Duration::from_secs(30)))
14243                .unwrap();
14244            barrier.write_all(&pid.to_ne_bytes()).unwrap();
14245            let mut release = [0];
14246            barrier.read_exact(&mut release).unwrap();
14247            assert_eq!(&release, b"X");
14248        }
14249    }
14250
14251    #[cfg(target_os = "macos")]
14252    fn spec(program: &str, args: &[&str]) -> ModuleSpec {
14253        ModuleSpec {
14254            module_id: "privacy-boundary".into(),
14255            program: program.into(),
14256            args: args.iter().map(|arg| (*arg).into()).collect(),
14257            env: vec![],
14258            reserved: false,
14259            reserved_prefixes: vec![],
14260            protocol: ModuleProtocol::None,
14261            overlap: ModuleOverlap::Exclusive,
14262        }
14263    }
14264
14265    #[cfg(target_os = "macos")]
14266    async fn accept(listener: TcpListener) -> TcpStream {
14267        // Socket readiness, not elapsed time, establishes both pause points.
14268        let listener = tokio::net::TcpListener::from_std({
14269            listener.set_nonblocking(true).unwrap();
14270            listener
14271        })
14272        .unwrap();
14273        let (stream, _) = tokio::time::timeout(Duration::from_secs(30), listener.accept())
14274            .await
14275            .unwrap()
14276            .unwrap();
14277        let stream = stream.into_std().unwrap();
14278        stream.set_nonblocking(false).unwrap();
14279        stream
14280            .set_read_timeout(Some(Duration::from_secs(30)))
14281            .unwrap();
14282        stream
14283    }
14284
14285    #[tokio::test]
14286    #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
14287    async fn macos_roster_withholds_a_nonnull_trampoline_image_until_exec_confirmation() {
14288        #[cfg(target_os = "macos")]
14289        {
14290            let root = cortexkit_test_support::ScratchDir::new("privacy-roster-barrier");
14291            // Loopback sockets also work when the replay adapter's TMPDIR is
14292            // longer than Darwin's Unix-domain socket path limit.
14293            let exec_listener = TcpListener::bind("127.0.0.1:0").unwrap();
14294            let sample_listener = TcpListener::bind("127.0.0.1:0").unwrap();
14295            let record = root.join("live-children.json");
14296            let supervisor =
14297                Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
14298                    .with_live_children_record(&record);
14299            let runtime = supervisor.runtime_config();
14300            let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
14301            let mut spec = spec("/bin/sleep", &["30"]);
14302            spec.env = vec![
14303                (
14304                    "SUBC_TEST_PRIVACY_EXEC_BARRIER".into(),
14305                    exec_listener.local_addr().unwrap().to_string(),
14306                ),
14307                (
14308                    "SUBC_TEST_PRIVACY_SAMPLE_BARRIER".into(),
14309                    sample_listener.local_addr().unwrap().to_string(),
14310                ),
14311            ];
14312            let spawn = tokio::task::spawn_blocking(move || {
14313                spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap()
14314            });
14315            let mut sample = accept(sample_listener).await;
14316            let mut pid = [0; 4];
14317            sample.read_exact(&mut pid).unwrap();
14318            let pid = u32::from_ne_bytes(pid);
14319            let mut exec = accept(exec_listener).await;
14320            let mut ready = [0];
14321            exec.read_exact(&mut ready).unwrap();
14322            assert_eq!(&ready, b"R");
14323            // The early read is guaranteed to see a real, non-null trampoline
14324            // image: the fixture has reached its barrier and cannot exec yet.
14325            let trampoline = subc_os::file_identity(&test_privacy_trampoline()).unwrap();
14326            assert_eq!(
14327                observe_spawned_image(pid).unwrap().executable,
14328                Some(trampoline)
14329            );
14330            sample.write_all(b"X").unwrap();
14331            let mut child = spawn.await.unwrap();
14332            let early = crate::live_children::read_record(&record).unwrap();
14333            assert_eq!(early.len(), 1);
14334            assert_eq!(early[0].pid, pid);
14335            assert_eq!(
14336                early[0].executable, None,
14337                "unconfirmed trampoline image entered the roster"
14338            );
14339            assert!(child.report_ready.get().is_none());
14340            // The barrier's duration is unrelated to the production five-second
14341            // exec budget. Start the test's confirmation budget upon release.
14342            child.privacy_exec.as_mut().unwrap().deadline =
14343                tokio::time::Instant::now() + Duration::from_secs(30);
14344            exec.write_all(b"X").unwrap();
14345            child.confirm_privacy_exec().await;
14346            assert_eq!(child.spawn_failure, None);
14347            assert!(child.report_ready.get().is_some());
14348            let confirmed = crate::live_children::read_record(&record).unwrap();
14349            let module = subc_os::file_identity(std::path::Path::new("/bin/sleep")).unwrap();
14350            assert_ne!(module, trampoline);
14351            assert_eq!(confirmed[0].executable, Some(module.into()));
14352            child.start_kill().unwrap();
14353            child.wait().await.unwrap();
14354            child.release_roster();
14355        }
14356    }
14357
14358    #[tokio::test]
14359    #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
14360    async fn macos_already_exited_121_in_try_wait_is_a_module_exit_not_a_trampoline_refusal() {
14361        #[cfg(target_os = "macos")]
14362        {
14363            let registry = Arc::new(Registry::default());
14364            let policy = RestartPolicy::new(0, Duration::ZERO);
14365            let supervisor = Supervisor::new_for_test(Arc::clone(&registry), policy);
14366            let runtime = supervisor.runtime_config();
14367            let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
14368            let spec = spec("/bin/sh", &["-c", "exit 121"]);
14369            // Drive spawn and confirmation separately instead of starting the
14370            // monitor. WNOWAIT observes a real exit without consuming its status,
14371            // so confirmation's first try_wait must take the already-exited arm.
14372            let mut child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
14373            let pid = child.pid;
14374            tokio::task::spawn_blocking(move || {
14375                subc_os::fork_exec_test::wait_for_child_exit_without_reaping(pid)
14376            })
14377            .await
14378            .unwrap()
14379            .unwrap();
14380            child.privacy_exec.as_mut().unwrap().deadline =
14381                tokio::time::Instant::now() + Duration::from_secs(30);
14382            let status = child.wait().await.unwrap();
14383            assert_eq!(status.code(), Some(121));
14384            assert!(child.privacy_exec.is_none());
14385            assert!(
14386                child.report_ready.get().is_none(),
14387                "an exited module must not publish a live pid"
14388            );
14389            let report = classify_reaped_child_exit(&snapshot, &child, &status);
14390            on_child_exit(
14391                &spec,
14392                policy,
14393                &registry,
14394                &snapshot,
14395                &runtime.terminal_ring,
14396                &runtime.spawn_events,
14397                &runtime.child_roster,
14398                report,
14399            )
14400            .await;
14401            let state = lock_snapshot(&snapshot).unwrap();
14402            assert_eq!(state.state, ModuleState::Failed);
14403            assert_eq!(state.last_exit.as_ref().unwrap().code, Some(121));
14404            assert_eq!(state.reported_pid(), None);
14405            drop(state);
14406            let history = runtime.terminal_ring.lock().unwrap().snapshot();
14407            assert_eq!(history.entries.len(), 1);
14408            let terminal = &history.entries[0];
14409            assert_eq!(terminal.exit_code, Some(121));
14410            assert_eq!(terminal.exit_kind, subc_control::TerminalExitKind::Crash);
14411            assert_eq!(terminal.disposition, TerminalDisposition::Failed);
14412            assert_eq!(
14413                terminal.disposition_detail.as_deref(),
14414                Some(policy.budget_exhausted_detail().as_str()),
14415                "module exit 121 was classified as a trampoline refusal: {terminal:?}"
14416            );
14417            assert_eq!(child.spawn_failure, None);
14418            child.release_roster();
14419        }
14420    }
14421}
14422
14423/// The daemon's real spawn path hands a subc-wire child its launch nonce on
14424/// descriptor 3, without an environment copy. The shell records the nonce
14425/// and its environment after exec so these tests observe the real handover.
14426#[cfg(all(test, unix))]
14427mod launch_nonce_descriptor_tests {
14428    use super::{ChildRoster, ModuleProtocol, ModuleSpec, SupervisorHandle};
14429    use crate::stderr_tail::{StderrRing, StderrTailConfig};
14430    use cortexkit_test_support::ScratchDir;
14431    use std::{
14432        path::PathBuf,
14433        sync::{Arc, Mutex},
14434        time::{Duration, Instant},
14435    };
14436
14437    async fn probe(role: super::SpawnRole) {
14438        let scratch = ScratchDir::new("launch-nonce-descriptor");
14439        let fd_copy = scratch.join("from-descriptor");
14440        let env_copy = scratch.join("environment");
14441        let script = format!(
14442            "env > '{env}.tmp' && mv '{env}.tmp' '{env}'; cat <&3 > '{fd}.tmp' && mv '{fd}.tmp' '{fd}'; sleep 30",
14443            fd = fd_copy.display(), env = env_copy.display(),
14444        );
14445        let xdg = |name: &str| (name.to_string(), scratch.join(name).display().to_string());
14446        let spec = ModuleSpec {
14447            module_id: "nonce-descriptor-probe".to_string(),
14448            program: PathBuf::from("/bin/sh"),
14449            args: vec!["-c".to_string(), script],
14450            env: vec![
14451                xdg("XDG_DATA_HOME"),
14452                xdg("XDG_RUNTIME_DIR"),
14453                xdg("XDG_CONFIG_HOME"),
14454                ("SUBC_LAUNCH_NONCE".to_string(), "must-not-leak".to_string()),
14455            ],
14456            reserved: true,
14457            reserved_prefixes: Vec::new(),
14458            protocol: ModuleProtocol::Subc,
14459            overlap: Default::default(),
14460        };
14461        let handle = SupervisorHandle::new();
14462        let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
14463        let roster = ChildRoster::default();
14464        #[cfg(target_os = "macos")]
14465        {
14466            let path = super::test_privacy_trampoline();
14467            roster.set_privacy_trampoline(super::probe_privacy_trampoline(&path).map(|()| path));
14468        }
14469        let child = super::spawn_child_in_slot(
14470            &spec,
14471            None,
14472            Some(&handle),
14473            &ring,
14474            None,
14475            &roster,
14476            #[cfg(target_os = "linux")]
14477            None,
14478            role,
14479            matches!(role, super::SpawnRole::SwapCandidate),
14480        )
14481        .expect("spawn probe");
14482        let deadline = Instant::now() + Duration::from_secs(10);
14483        while !(fd_copy.exists() && env_copy.exists()) {
14484            assert!(Instant::now() < deadline, "probe never wrote its copies");
14485            tokio::time::sleep(Duration::from_millis(20)).await;
14486        }
14487        let nonce = std::fs::read_to_string(fd_copy).unwrap();
14488        assert!(!nonce.is_empty());
14489        let environment = std::fs::read_to_string(env_copy).unwrap();
14490        assert!(environment
14491            .lines()
14492            .any(|line| line.starts_with("SUBC_LAUNCH_NONCE_FD=3:")));
14493        let copy = environment
14494            .lines()
14495            .find_map(|line| line.strip_prefix("SUBC_LAUNCH_NONCE="));
14496        assert_eq!(
14497            copy, None,
14498            "Unix children must never receive the environment nonce"
14499        );
14500        if matches!(role, super::SpawnRole::Plain) {
14501            assert_eq!(
14502                handle.spawn_nonce(&spec.module_id).as_deref(),
14503                Some(nonce.as_str())
14504            );
14505        }
14506        drop(child);
14507    }
14508
14509    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
14510    async fn a_spawned_module_receives_its_nonce_only_on_descriptor_3() {
14511        probe(super::SpawnRole::Plain).await;
14512    }
14513
14514    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
14515    async fn swap_candidate_receives_its_nonce_only_on_descriptor_3() {
14516        probe(super::SpawnRole::SwapCandidate).await;
14517    }
14518}
14519
14520#[cfg(all(test, target_os = "linux"))]
14521mod cgroup_containment_tests {
14522    use super::*;
14523    use cortexkit_test_support::ScratchDir;
14524
14525    fn running(pid: u32) -> bool {
14526        // An orphan can remain a zombie until the container init reaps it.
14527        std::fs::read_to_string(format!("/proc/{pid}/stat"))
14528            .ok()
14529            .and_then(|stat| {
14530                stat.rsplit_once(") ")
14531                    .map(|(_, rest)| rest.starts_with('Z'))
14532            })
14533            .is_some_and(|zombie| !zombie)
14534    }
14535
14536    #[tokio::test]
14537    async fn linux_teardown_reaps_the_grandchild() {
14538        teardown_tree("linux_teardown_reaps_the_grandchild", false).await;
14539    }
14540
14541    #[tokio::test]
14542    async fn linux_shutdown_straggler_reaps_the_grandchild() {
14543        teardown_tree("linux_shutdown_straggler_reaps_the_grandchild", true).await;
14544    }
14545
14546    async fn teardown_tree(test_name: &str, shutdown: bool) {
14547        let dir = ScratchDir::new(test_name);
14548        let root = PathBuf::from(format!(
14549            "/sys/fs/cgroup/subc-tree-test-{test_name}-{}-{}",
14550            std::process::id(),
14551            unix_ms_now()
14552        ));
14553        if let Err(error) = std::fs::create_dir(&root) {
14554            assert!(
14555                std::env::var_os("SUBC_REQUIRE_CGROUP_TEST").is_none(),
14556                "required cgroup test cannot execute: {error}"
14557            );
14558            eprintln!(
14559                "SKIP {test_name}: no writable delegated cgroup at {}: {error}",
14560                root.display()
14561            );
14562            return;
14563        }
14564        let placement = subc_cgroup::prepare_at(&root)
14565            .expect("prepare isolated kernel cgroup")
14566            .expect("isolated cgroup is delegated");
14567        let module_id = "tree-teardown";
14568        let module = placement
14569            .module_path(module_id)
14570            .expect("create isolated module cgroup");
14571        if !module.join("cgroup.kill").exists() {
14572            std::fs::remove_dir(&module).unwrap();
14573            std::fs::remove_dir(root.join("subc-modules")).unwrap();
14574            std::fs::remove_dir(&root).unwrap();
14575            assert!(
14576                std::env::var_os("SUBC_REQUIRE_CGROUP_TEST").is_none(),
14577                "required cgroup.kill interface unavailable"
14578            );
14579            eprintln!("SKIP {test_name}: cgroup.kill unavailable (kernel < 5.14)");
14580            return;
14581        }
14582        let supervisor = Supervisor::new_for_test(
14583            Arc::new(Registry::default()),
14584            RestartPolicy::new(3, Duration::ZERO),
14585        )
14586        .with_cgroup_placement(Some(placement));
14587        let mut runtime = supervisor.runtime_config();
14588        runtime.child_roster = runtime
14589            .child_roster
14590            .for_module(Arc::new(Mutex::new(Duration::from_millis(100))));
14591        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
14592        let pid_file = dir.join("grandchild.pid");
14593        let spec = ModuleSpec {
14594            module_id: module_id.to_string(),
14595            program: PathBuf::from("/bin/sh"),
14596            args: vec![
14597                "-c".into(),
14598                "trap '' TERM; sleep 600 & echo $! > \"$1\"; wait".into(),
14599                "fixture".into(),
14600                pid_file.display().to_string(),
14601            ],
14602            env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
14603                .into_iter()
14604                .map(|key| (key.to_string(), dir.display().to_string()))
14605                .collect(),
14606            reserved: false,
14607            reserved_prefixes: Vec::new(),
14608            protocol: ModuleProtocol::None,
14609            overlap: Default::default(),
14610        };
14611        let child =
14612            spawn_and_mark_running(&spec, &runtime, &snapshot).expect("spawn supervised tree");
14613        let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
14614        let grandchild: u32 = loop {
14615            if let Ok(contents) = std::fs::read_to_string(&pid_file) {
14616                if let Ok(pid) = contents.trim().parse() {
14617                    break pid;
14618                }
14619            }
14620            assert!(
14621                tokio::time::Instant::now() < deadline,
14622                "grandchild pid was not recorded"
14623            );
14624            tokio::time::sleep(Duration::from_millis(10)).await;
14625        };
14626        assert!(
14627            running(grandchild),
14628            "grandchild must be alive before teardown"
14629        );
14630        if shutdown {
14631            let mut child = child;
14632            crate::child_roster::end_children_for_daemon_shutdown(
14633                &runtime.child_roster,
14634                false,
14635                std::future::pending(),
14636            )
14637            .await;
14638            child.wait().await.expect("reap shutdown straggler");
14639        } else {
14640            drain_child_to_state(
14641                module_id,
14642                ModuleProtocol::None,
14643                StopNotice::NotSent,
14644                &Registry::default(),
14645                None,
14646                &snapshot,
14647                &runtime.terminal_ring,
14648                &SpawnEventFeed::default(),
14649                child,
14650                Duration::from_millis(100),
14651                ModuleState::Stopped,
14652                Some(false),
14653            )
14654            .await
14655            .expect("real supervisor teardown");
14656        }
14657        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
14658        while running(grandchild) && tokio::time::Instant::now() < deadline {
14659            tokio::time::sleep(Duration::from_millis(10)).await;
14660        }
14661        let survived = running(grandchild);
14662        // Kill a surviving grandchild so a failed test does not leave it behind.
14663        if survived {
14664            let pid = rustix::process::Pid::from_raw(grandchild as i32).unwrap();
14665            let _ = rustix::process::kill_process(pid, rustix::process::Signal::KILL);
14666            tokio::time::sleep(Duration::from_millis(100)).await;
14667        }
14668        if module.exists() {
14669            std::fs::remove_dir(&module).expect("remove empty module cgroup");
14670        }
14671        std::fs::remove_dir(root.join("subc-modules")).unwrap();
14672        std::fs::remove_dir(&root).unwrap();
14673        assert!(
14674            !survived,
14675            "grandchild {grandchild} outlived module teardown"
14676        );
14677        eprintln!("EXECUTED {test_name}: grandchild {grandchild} killed in isolated cgroup");
14678    }
14679}