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::{spawned_file_identity, 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#[path = "supervise_swap.rs"]
53mod swap;
54
55/// Command-line flag used by supervised modules to find subc.
56///
57/// subc launches module-mode children as `<module> --subc <connection-file-path>`.
58/// The path points at the TCP+key connection file; it is not an ambient signal and
59/// is never inherited by standalone children.
60pub const SUBC_ARG: &str = "--subc";
61
62const DEFAULT_MAX_RESTARTS: u32 = 3;
63const DEFAULT_BACKOFF: Duration = Duration::from_millis(100);
64const DEFAULT_MAX_BACKOFF: Duration = Duration::from_secs(30);
65/// The span `DEFAULT_MAX_RESTARTS` is counted over. Ten minutes is long enough
66/// to contain a real crash loop (which respawns in seconds) and short enough
67/// that unrelated crashes hours apart never accumulate into a permanent stop.
68const DEFAULT_RESTART_WINDOW: Duration = Duration::from_secs(600);
69/// How long a drain waits for already-dispatched requests to finalize before
70/// the child is torn down. Sized for TOOL-SCALE work (bash, inspect, builds),
71/// not RPC-scale: the original 2s value silently cut nearly every real tool
72/// call at the fence, making the wait-for-finalize design decorative for the
73/// workloads it existed for. Quiescence short-circuits, so an idle module
74/// restarts immediately regardless of this value; the budget is spent only
75/// when a genuine in-flight request is worth finishing. Per-module override:
76/// `drain_timeout_ms` in subc.jsonc; per-restart override: the operator's
77/// `supervisor.restart{drain_timeout_ms}` (0 = cut now, for wedge bounces
78/// where a stuck request will never settle).
79pub const DEFAULT_DRAIN_TIMEOUT: Duration = Duration::from_secs(30);
80const REGISTRY_RELEASE_TIMEOUT: Duration = Duration::from_secs(1);
81const REGISTRY_RELEASE_POLL: Duration = Duration::from_millis(10);
82/// How long a restart waits for the exited process's output readers.
83///
84/// The restart does not depend on either output reader finishing. A stderr
85/// reader still running at this bound is left running, and its later output goes
86/// into the exited process's own section of the stderr ring (see
87/// `StderrRing::push_line_from`), ending naturally at EOF on its pipe. So the
88/// bound no longer decides whether a crash's last lines are kept: under load
89/// the reader may simply not have been scheduled yet, and cutting it there
90/// discarded exactly the lines that explained the crash.
91///
92/// What the bound still decides is when the tail starts reporting
93/// `Incomplete`: a pipe open past it usually means a descendant of the exited
94/// process still holds it, and the tail cannot claim to be whole until that
95/// pipe closes. That is also the only case in which the wait costs the restart
96/// anything, because a pipe with no other holder reaches EOF when the process
97/// exits. Under load a slow reader can show `Incomplete` briefly; it returns to
98/// `Captured` at EOF with nothing lost.
99///
100/// The stdout reader carries no ring, only the capture file. It also continues
101/// to EOF after this bound: child exit closes the writer but does not mean the
102/// reader has consumed all buffered bytes. Late output from either pipe can
103/// therefore reach the shared capture file after the successor starts.
104const STDERR_PUMP_DRAIN_TIMEOUT: Duration = Duration::from_millis(250);
105/// Maximum number of supervised process spawn/exit facts retained per daemon incarnation.
106pub const SPAWN_EVENT_RING_CAPACITY: usize = 4096;
107const SPAWN_SUBSCRIBER_BUFFER: usize = SPAWN_EVENT_RING_CAPACITY + 1;
108/// Terminal Error code for a `supervisor.spawn_subscribe` stream the daemon
109/// dropped because the subscriber stopped draining its frames. The detail's
110/// `first_undelivered_cursor` names the first event it did not receive; the
111/// client resubscribes from the last cursor it did receive.
112pub(crate) const SPAWN_SUBSCRIBER_LAGGED_CODE: &str = "spawn_subscriber_lagged";
113
114#[cfg(target_os = "macos")]
115struct PrivacyExec {
116    reader: tokio::io::unix::AsyncFd<std::io::PipeReader>,
117    deadline: tokio::time::Instant,
118    expected: Option<subc_os::FileIdentity>,
119    trampoline: Option<subc_os::FileIdentity>,
120    script: bool,
121    module_id: String,
122}
123
124#[cfg(all(test, target_os = "macos"))]
125pub(crate) fn test_privacy_trampoline() -> PathBuf {
126    // Cargo's unit-test executable lives in <profile>/deps; its fixture bin
127    // lives beside that directory. This honors custom CARGO_TARGET_DIR too.
128    let path = std::env::current_exe()
129        .unwrap()
130        .parent()
131        .unwrap()
132        .parent()
133        .unwrap()
134        .join("privacy-trampoline-fixture");
135    // Without the fixture every macOS spawn is refused, and the tests that
136    // spawn fail later as a module in state Failed, which names the wrong
137    // cause. `cargo test -p subc-daemon --lib` alone does not build it.
138    assert!(
139        path.exists(),
140        "privacy-trampoline-fixture not built at {}: run `cargo build -p subc-daemon \
141         --bins --features test-support` or `cargo test -p subc-daemon` first",
142        path.display()
143    );
144    path
145}
146
147#[cfg(target_os = "macos")]
148fn probe_privacy_trampoline(path: &std::path::Path) -> Result<(), String> {
149    use std::io::Read;
150    let mut probe = std::process::Command::new(path)
151        .args(["__disclaim-exec", "--probe"])
152        .stdin(Stdio::null())
153        .stdout(Stdio::piped())
154        .stderr(Stdio::piped())
155        .spawn()
156        .map_err(|error| {
157            format!(
158                "privacy trampoline probe failed for {}: {error}",
159                path.display()
160            )
161        })?;
162    let deadline = std::time::Instant::now() + Duration::from_secs(5);
163    let status = loop {
164        match probe.try_wait() {
165            Ok(Some(status)) => break status,
166            Ok(None) if std::time::Instant::now() < deadline => {
167                std::thread::sleep(Duration::from_millis(5))
168            }
169            result => {
170                let _ = probe.kill();
171                let _ = probe.wait();
172                return Err(format!(
173                    "privacy trampoline probe failed or timed out for {}: {result:?}",
174                    path.display()
175                ));
176            }
177        }
178    };
179    let mut answer = String::new();
180    if let Some(stdout) = probe.stdout.take() {
181        let _ = stdout.take(256).read_to_string(&mut answer);
182    }
183    if status.success() && answer.trim() == subc_os::privacy_identity::TRAMPOLINE_PROBE {
184        return Ok(());
185    }
186    let mut diagnostic = String::new();
187    if let Some(stderr) = probe.stderr.take() {
188        let _ = stderr.take(1024).read_to_string(&mut diagnostic);
189    }
190    let cause = diagnostic
191        .trim()
192        .strip_prefix("ck-subc: own privacy identity refused: ")
193        .unwrap_or("binary does not implement the privacy trampoline protocol");
194    Err(format!(
195        "{cause}: probe of {} exited {status}",
196        path.display()
197    ))
198}
199
200#[cfg(target_os = "macos")]
201fn privacy_command(
202    spec: &ModuleSpec,
203    roster: &ChildRoster,
204) -> Result<
205    (
206        Command,
207        Option<PrivacyExec>,
208        subc_os::privacy_identity::ExecAcknowledgement,
209    ),
210    SuperviseError,
211> {
212    let failure = |cause: String| {
213        warn!(module_id = %spec.module_id, %cause, "privacy identity trampoline refused module spawn");
214        SuperviseError::Spawn {
215            program: spec.program.clone(),
216            source: io::Error::other(cause),
217            cgroup_path: None,
218        }
219    };
220    let trampoline = roster.privacy_trampoline().map_err(failure)?;
221    // Resolve PATH with the same environment the Command will receive. For
222    // scripts retain the existing orphan-identity rule: the kernel chooses
223    // the interpreter, and its observed image is the one recorded. Do not
224    // duplicate the kernel's shebang/PATH interpreter resolution in Rust.
225    let program = if spec.program.components().count() == 1 && !spec.program.is_absolute() {
226        let path = spec
227            .env
228            .iter()
229            .find(|(key, _)| key == "PATH")
230            .map(|(_, value)| std::ffi::OsString::from(value))
231            .or_else(|| std::env::var_os("PATH"))
232            .unwrap_or_else(|| "/usr/bin:/bin".into());
233        std::env::split_paths(&path)
234            .map(|dir| dir.join(&spec.program))
235            .find(|path| path.is_file())
236            .unwrap_or_else(|| spec.program.clone())
237    } else {
238        spec.program.clone()
239    };
240    let expected = subc_os::file_identity(&program);
241    let trampoline_image = subc_os::file_identity(&trampoline);
242    let script = {
243        use std::io::Read;
244        let mut prefix = [0u8; 2];
245        std::fs::File::open(&program)
246            .is_ok_and(|mut file| file.read_exact(&mut prefix).is_ok() && &prefix == b"#!")
247    };
248    if expected.is_none() || expected == subc_os::file_identity(&trampoline) {
249        return Err(failure(
250            "privacy identity module executable is missing or is the trampoline itself".to_string(),
251        ));
252    }
253    let (reader, ack) = subc_os::privacy_identity::ExecAcknowledgement::pipe()
254        .map_err(|error| failure(error.to_string()))?;
255    let reader =
256        tokio::io::unix::AsyncFd::new(reader).map_err(|error| failure(error.to_string()))?;
257    let mut command = Command::new(&trampoline);
258    command
259        .arg("__disclaim-exec")
260        .arg(ack.fd().to_string())
261        .arg(&program);
262    ack.install(command.as_std_mut());
263    Ok((
264        command,
265        Some(PrivacyExec {
266            reader,
267            deadline: tokio::time::Instant::now() + Duration::from_secs(5),
268            expected,
269            trampoline: trampoline_image,
270            script,
271            module_id: spec.module_id.clone(),
272        }),
273        ack,
274    ))
275}
276
277struct SupervisedChild {
278    child: Child,
279    #[cfg(target_os = "macos")]
280    privacy_exec: Option<PrivacyExec>,
281    /// Set once this launch's exec acknowledgement confirms the module image.
282    /// On macOS the pid first runs the `ck-subc` launch trampoline (see
283    /// `subc_os::privacy_identity`), which then replaces itself with the
284    /// module. The supervisor owns and can kill that pid from spawn, but
285    /// status readers report it only after this latch is set, so nothing
286    /// reports the trampoline's image as the module's.
287    #[cfg(target_os = "macos")]
288    report_ready: Arc<OnceLock<()>>,
289    /// Refusal before the module image was accepted, retained for terminal records.
290    spawn_failure: Option<String>,
291    /// The protocol this process was launched with. A reload can store a new
292    /// launch spec with a different protocol, but that takes effect only at the
293    /// next spawn, so this process keeps being handled by the protocol it
294    /// actually speaks.
295    protocol: ModuleProtocol,
296    /// This process's cgroup name: a bounded module/slot label followed by a
297    /// spawn suffix unique to this process (when cgroup placement is on). A
298    /// retired process in a slot may still be draining when a later one is
299    /// spawned into that slot, so the suffix keeps the later process out of
300    /// the retired one's cgroup, which is the domain a kill applies to.
301    #[cfg(target_os = "linux")]
302    module_id: String,
303    #[cfg(target_os = "linux")]
304    cgroup_placement: Option<subc_cgroup::Placement>,
305    /// The job that contains this child and every process it spawns (issue #109).
306    ///
307    /// Dropping this handle is what reaps a surviving tree when no supervisor
308    /// code runs — a daemon crash — because the job carries
309    /// `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`.
310    ///
311    /// That limit is not crash-only, and the difference is worth knowing: a
312    /// Windows daemon *stop* is `taskkill` or the scheduler's `/End` — the
313    /// SIGTERM handler is `#[cfg(unix)]` — so the daemon dies with no stop
314    /// notice and the kernel closes the job handle, `TerminateProcess`ing every
315    /// module at once. Before this change they survived that, saw EOF on the
316    /// control socket, and ran their own teardown; Unix keeps that path
317    /// deliberately, so a module can seal a WAL or close a capture rather than
318    /// be killed mid-write. So this trades graceful teardown on every Windows
319    /// daemon stop for containment on a crash, which is the right way round
320    /// today: orphaned GPU workers are a reported, recurring problem, and the
321    /// modules that write most heavily do not run on Windows.
322    ///
323    /// The fix is a real Windows stop path — the daemon draining before it
324    /// exits, the twin of the Unix SIGTERM handler. Once it exists, this limit
325    /// reaches only what the drain left behind, which is what it should reach.
326    #[cfg(windows)]
327    job: Option<subc_jobobject::JobObject>,
328    stdout_pump: Option<JoinHandle<()>>,
329    stderr_pump: Option<StderrPump>,
330    stderr_ring: Arc<Mutex<StderrRing>>,
331    spawned_at_ms: u64,
332    spawned_from: PathBuf,
333    spawned_file_identity: Option<SpawnedFileIdentity>,
334    process_start_time: Option<u64>,
335    process_identity: Option<ProcessIdentity>,
336    pid: u32,
337    /// This process's entry in the daemon's child roster, released when the
338    /// process is reaped or this handle is dropped.
339    roster_guard: Option<crate::child_roster::RosterGuard>,
340}
341
342impl SupervisedChild {
343    fn id(&self) -> Option<u32> {
344        Some(self.pid)
345    }
346
347    fn process_identity(&self) -> Option<ProcessIdentity> {
348        self.process_identity
349    }
350
351    async fn wait(&mut self) -> io::Result<ExitStatus> {
352        #[cfg(target_os = "macos")]
353        self.confirm_privacy_exec().await;
354        // The roster entry is NOT released here. A daemon shutdown waits for the
355        // roster to empty and then exits the process, so releasing at the reap
356        // let it exit before the exit handler wrote this child's terminal record
357        // (the stderr drain and snapshot update sit in between), and the
358        // shutdown's own `daemon_shutdown` record was intermittently lost. The
359        // caller releases it after recording the exit (`release_roster`), and
360        // dropping the handle releases it too.
361        let result = self.child.wait().await;
362        #[cfg(target_os = "linux")]
363        if result.is_ok() {
364            if let Some(placement) = self.cgroup_placement.as_ref() {
365                cleanup_reaped_cgroup(placement, &self.module_id).await;
366                // Keep ownership while awaiting kernel population changes: a
367                // drain timeout may cancel this wait and then escalate/reap.
368                self.cgroup_placement = None;
369            }
370        }
371        result
372    }
373
374    #[cfg(target_os = "macos")]
375    async fn confirm_privacy_exec(&mut self) {
376        let Some(pending) = &mut self.privacy_exec else {
377            return;
378        };
379        let result = tokio::time::timeout_at(pending.deadline, async {
380            let mut record = Vec::new();
381            loop {
382                let mut ready = pending.reader.readable().await?;
383                let read = ready.try_io(|reader| {
384                    use std::io::Read;
385                    let mut reader = reader.get_ref();
386                    let mut buffer = [0u8; 256];
387                    reader.read(&mut buffer).map(|count| (count, buffer))
388                });
389                match read {
390                    Ok(Ok((0, _))) => return Ok::<_, io::Error>(record),
391                    Ok(Ok((count, buffer))) => {
392                        if record.len() + count > 1024 {
393                            return Err(io::Error::other(
394                                "privacy exec refusal record is too long",
395                            ));
396                        }
397                        record.extend_from_slice(&buffer[..count]);
398                    }
399                    Ok(Err(error)) => return Err(error),
400                    Err(_) => continue,
401                }
402            }
403        })
404        .await;
405        // Keep the reader in self across await: select cancellation must not
406        // discard the handshake or reset its original five-second deadline.
407        let pending = self.privacy_exec.as_ref().expect("pending exec");
408        let cause = match result {
409            Err(_) => Some("privacy identity trampoline did not exec within 5s".to_string()),
410            Ok(Err(error)) => Some(format!(
411                "privacy identity exec acknowledgement failed: {error}"
412            )),
413            Ok(Ok(record)) if !record.is_empty() => Some(
414                std::str::from_utf8(&record)
415                    .ok()
416                    .and_then(|record| {
417                        record
418                            .trim()
419                            .strip_prefix(subc_os::privacy_identity::EXEC_REFUSAL_TAG)
420                    })
421                    .filter(|cause| !cause.is_empty())
422                    .unwrap_or("invalid privacy exec refusal record")
423                    .to_string(),
424            ),
425            Ok(Ok(_)) => match self.child.try_wait() {
426                // Empty EOF is the exec acknowledgement. A real module may exit
427                // immediately, including with a reserved trampoline status; no
428                // image is admitted, and its ordinary exit contract stays intact.
429                Ok(Some(_status)) => None,
430                Err(error) => Some(format!("privacy identity trampoline wait failed: {error}")),
431                Ok(None) => {
432                    let image = observe_spawned_image(self.pid);
433                    if let Some(image) = image.filter(|image| {
434                        image.executable.is_some()
435                            && image.executable != pending.trampoline
436                            && (image.executable == pending.expected || pending.script)
437                    }) {
438                        if let Some(guard) = &self.roster_guard {
439                            guard.confirm_executable(image);
440                        }
441                        let _ = self.report_ready.set(());
442                        info!(module_id = %pending.module_id, pid = self.pid,
443                            "module spawned with own privacy identity (responsibility disclaimed)");
444                        None
445                    } else if image.is_none()
446                        || image.is_some_and(|image| image.executable.is_none())
447                    {
448                        // A process can exit between try_wait and the kernel
449                        // image read. Empty EOF already acknowledged exec, so
450                        // preserve that module's ordinary exit rather than
451                        // mislabel a disappearing image as trampoline refusal.
452                        // Keep pending in self across await so cancellation does
453                        // not discard validation or reset its original deadline.
454                        match tokio::time::timeout_at(pending.deadline, self.child.wait()).await {
455                            Ok(Ok(_status)) => None,
456                            Ok(Err(error)) => Some(format!("privacy identity trampoline wait failed: {error}")),
457                            Err(_) => Some("privacy identity module executable remained unreadable until the 5s exec deadline".to_string()),
458                        }
459                    } else {
460                        Some("privacy identity trampoline executable mismatch: expected the module program, not ck-subc".to_string())
461                    }
462                }
463            },
464        };
465        let pending = self.privacy_exec.take().expect("pending exec");
466        if let Some(cause) = cause {
467            warn!(module_id = %pending.module_id, pid = self.pid, %cause, "privacy identity trampoline refused module spawn");
468            self.spawn_failure = Some(cause);
469            // No image is admitted on failure. Reach the entire fresh process
470            // group, including a module which spawned a helper before refusal.
471            if let Some(pid) = rustix::process::Pid::from_raw(self.pid as i32) {
472                let _ = rustix::process::kill_process_group(pid, rustix::process::Signal::KILL);
473            }
474            let _ = self.child.start_kill();
475        }
476    }
477
478    /// Releases this child's daemon-shutdown roster entry once its exit has
479    /// been recorded. The pid is already reaped and free for reuse, so the
480    /// entry must not outlive the record any longer than that.
481    fn release_roster(&mut self) {
482        self.roster_guard = None;
483    }
484
485    /// Kill the child and, where containment is available, its process tree.
486    ///
487    /// `Child::kill` is `TerminateProcess` scoped to one pid, so a module with a
488    /// helper process leaked the helper — the Synapse embedding module's CUDA
489    /// worker holds the GPU allocation, so the leak cost VRAM until the next
490    /// restart of something else. Terminating the job reaches grandchildren that
491    /// a tree walk cannot, including one whose parent has already exited and
492    /// been reparented away.
493    ///
494    /// Best-effort like `request_graceful_stop`: a job failure is logged and the
495    /// direct-child kill still decides the outcome, so containment can never
496    /// change whether a module is reported as stopped.
497    fn start_kill(&mut self) -> io::Result<()> {
498        #[cfg(windows)]
499        if let Some(job) = &self.job {
500            if let Err(error) = job.terminate() {
501                debug!(
502                    error = %error,
503                    "job termination failed; the direct-child kill still owns the outcome"
504                );
505            }
506        }
507        #[cfg(target_os = "linux")]
508        kill_module_cgroup(self.cgroup_placement.as_ref(), &self.module_id);
509        self.child.start_kill()
510    }
511
512    async fn drain_stderr(&mut self, module_id: &str) {
513        if let Some(pump) = self.stdout_pump.take() {
514            settle_stdout_pump(module_id, pump, STDERR_PUMP_DRAIN_TIMEOUT).await;
515        }
516
517        let Some(pump) = self.stderr_pump.take() else {
518            return;
519        };
520        settle_stderr_pump(
521            module_id,
522            &self.stderr_ring,
523            pump,
524            STDERR_PUMP_DRAIN_TIMEOUT,
525        )
526        .await;
527    }
528}
529
530/// Bound the restart's wait, not the lifetime of the exited child's stdout
531/// reader. Slow pipe reads or capture writes must not turn the bound into lost
532/// output; dropping the handle lets the reader finish at EOF, like stderr.
533async fn settle_stdout_pump(module_id: &str, mut pump: JoinHandle<()>, bound: Duration) {
534    match timeout(bound, &mut pump).await {
535        Ok(Ok(())) => {}
536        Ok(Err(error)) => {
537            warn!(module_id, error = %error, "stdout pump ended unexpectedly");
538        }
539        Err(_) => {
540            drop(pump);
541            warn!(
542                module_id,
543                waited = ?bound,
544                "stdout pipe of the exited process has not drained; its reader keeps running without delaying the restart"
545            );
546        }
547    }
548}
549
550/// The reader task for one process's stderr, with the ring generation its
551/// lines are attributed to.
552struct StderrPump {
553    task: JoinHandle<()>,
554    generation: u64,
555}
556
557/// Retire an exited process's stderr reader and wait up to `bound` for it to
558/// reach EOF. A reader still running at the bound is detached, not stopped: it
559/// keeps filling the exited process's section of the ring until its pipe
560/// closes, and the tail reads `Incomplete` until then. See
561/// [`STDERR_PUMP_DRAIN_TIMEOUT`] for why.
562async fn settle_stderr_pump(
563    module_id: &str,
564    ring: &Arc<Mutex<StderrRing>>,
565    pump: StderrPump,
566    bound: Duration,
567) {
568    let lock = || ring.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
569    let StderrPump {
570        mut task,
571        generation,
572    } = pump;
573    lock().retire_pump(generation);
574    match timeout(bound, &mut task).await {
575        Ok(Ok(())) => {}
576        Ok(Err(err)) => {
577            let mut ring = lock();
578            ring.mark_incomplete_from(generation, format!("stderr pump ended unexpectedly: {err}"));
579            ring.finish_pump(generation);
580            warn!(module_id, error = %err, "stderr pump ended before clean EOF");
581        }
582        Err(_) => {
583            // Dropping the handle detaches the task; it ends at EOF on its pipe.
584            drop(task);
585            lock().mark_pump_late(
586                generation,
587                format!(
588                    "stderr of the exited process had not reached EOF {bound:?} after it was \
589                     retired (a descendant may still hold the pipe open); lines it still \
590                     writes are kept in that process's section"
591                ),
592            );
593            warn!(
594                module_id,
595                waited = ?bound,
596                "stderr pipe of the exited process is still open; its reader keeps running without delaying the restart"
597            );
598        }
599    }
600}
601
602fn registration_release_events() -> &'static watch::Sender<u64> {
603    static EVENTS: OnceLock<watch::Sender<u64>> = OnceLock::new();
604    EVENTS.get_or_init(|| {
605        let (sender, _receiver) = watch::channel(0);
606        sender
607    })
608}
609
610pub(crate) fn notify_registration_release() {
611    let events = registration_release_events();
612    let next_generation = (*events.borrow()).wrapping_add(1);
613    events.send_replace(next_generation);
614}
615
616/// How to launch one singleton module process.
617#[derive(Debug, Clone, PartialEq, Eq)]
618pub struct ModuleSpec {
619    pub module_id: String,
620    pub program: PathBuf,
621    pub args: Vec<String>,
622    pub env: Vec<(String, String)>,
623    /// When true this is a reserved module: each spawn gets a fresh one-time launch
624    /// nonce that the child must echo in its HELLO, so only the daemon-spawned
625    /// process can register this module_id (a security-boundary module like the
626    /// credential vault must not be impersonable while it is down/restarting).
627    pub reserved: bool,
628    /// Module-id prefixes this supervised module owns for reserved HELLO checks.
629    /// Prefixes come from daemon config and must end in `:` before they reach the
630    /// supervisor; the owner module's current spawn nonce authorizes claims under
631    /// each prefix.
632    pub reserved_prefixes: Vec<String>,
633    /// The wire protocol this module speaks, as DECLARED in daemon config.
634    ///
635    /// [`ModuleProtocol::None`] changes five things and nothing else: wire health
636    /// probing is suppressed (an optional HTTP probe can replace it), teardown sends SIGTERM before waiting,
637    /// `route.open` is refused, the spawn passes NO `--subc <path>` argument
638    /// and NO launch nonce, and a clean exit the daemon did not request is
639    /// restarted as a crash rather than recorded as a stop (see `on_child_exit`:
640    /// a stock program exits 0 on a stray SIGTERM, and a stop would leave it
641    /// down for good). `SUBC_MODULE_ID` still goes into the environment,
642    /// because a process ignores an environment variable it does not read.
643    ///
644    /// The argument is the part that cannot be "harmless to a process that
645    /// ignores it": a stock binary exits on an unknown flag before it listens
646    /// (`nats-server`: "flag provided but not defined: -subc"), which is how the
647    /// first conformance run against this mode found it. The nonce is withheld
648    /// because a process that will never present it gains nothing from holding
649    /// it, and a secret in the environment of a process that does not need it is
650    /// a leak surface for no benefit.
651    pub protocol: ModuleProtocol,
652    /// Whether two processes of this module may run at once, which is what a
653    /// blue/green swap does for the length of its overlap. Declared in daemon
654    /// config because the daemon must be able to answer it while the module is
655    /// down, and so a module cannot talk itself into it after registering.
656    pub overlap: ModuleOverlap,
657}
658
659/// Whether a module tolerates a second process of itself running alongside.
660///
661/// Most modules are single-writer on their store (a WAL, a capture log, a
662/// resident index behind a writer barrier), and two processes on one store
663/// corrupt it. So a swap, which overlaps the old and new process by design,
664/// is refused unless the module's config opts in with `overlap: "safe"`.
665#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
666pub enum ModuleOverlap {
667    /// Never run two processes of this module at once. The default.
668    #[default]
669    Exclusive,
670    /// The module has said a second process of itself is harmless for the
671    /// length of a swap.
672    ///
673    /// Declare it only if a second instance can run for a few seconds without
674    /// touching ANY single-writer store: every database, WAL, index, projector
675    /// and scheduled job the module owns. A lease on part of that state is not
676    /// enough. broca's session lease guards WAL appends while its run index, its
677    /// store projector and its archive fold timer (which unlinks live WAL files)
678    /// stay single-writer, so broca is exclusive despite holding a lease. The
679    /// refusal only fires after this has been decided, so the decision is the
680    /// check.
681    Safe,
682}
683
684impl ModuleOverlap {
685    pub fn as_str(self) -> &'static str {
686        match self {
687            Self::Exclusive => "exclusive",
688            Self::Safe => "safe",
689        }
690    }
691}
692
693/// Environment variable telling a spawned module which case it was started
694/// for, before it sends HELLO. Only a swap candidate carries it, as
695/// [`SPAWN_ROLE_SWAP_CANDIDATE`]; every other spawn has it removed.
696///
697/// It chooses a warm-up budget, nothing else: a swap candidate can warm for
698/// longer because nobody waits on it, while a plain restart must flip ready
699/// quickly because callers see `module_warming` until it does. Absence means
700/// plain restart, the safe reading. The daemon trusts nothing about it; the
701/// candidate is proven by its launch nonce at HELLO.
702pub const SUBC_SPAWN_ROLE_ENV: &str = "SUBC_SPAWN_ROLE";
703/// The one value of [`SUBC_SPAWN_ROLE_ENV`] the daemon sets.
704pub const SPAWN_ROLE_SWAP_CANDIDATE: &str = "swap_candidate";
705/// How long a swap waits for its candidate to register and declare itself
706/// ready when the operator does not say. A module warming as a swap candidate
707/// may take up to 90 s (aft's ceiling, the largest in the fleet), so the
708/// daemon allows that plus time to start the process and send HELLO.
709pub const DEFAULT_SWAP_READY_TIMEOUT: Duration = Duration::from_secs(100);
710
711/// Bounded restart policy for crash exits.
712///
713/// `max_restarts` is the number of replacement processes allowed after the
714/// initial spawn WITHIN `window`. After that many crash restarts inside one
715/// window the module enters [`ModuleState::Failed`] and the supervisor stops
716/// the crash loop.
717///
718/// The budget is a RATE, not a lifetime total. It used to be a lifetime total,
719/// and that only survived because crashes were rare: a module that crashed
720/// three times across a week was disabled forever by crashes that had nothing
721/// to do with each other. That stopped being survivable once modules began
722/// exiting non-zero whenever the daemon's connection to them drops, because
723/// then every daemon-side connection drop spends a unit of the same budget and
724/// one flappy hour permanently stops a healthy module. Restarts older than
725/// `window` release their slot, so a module that crashed twice yesterday has a
726/// full budget today, while a genuine crash loop -- which is fast by
727/// definition -- still reaches the cap and stops.
728#[derive(Debug, Clone, Copy, PartialEq, Eq)]
729pub struct RestartPolicy {
730    pub max_restarts: u32,
731    /// Base delay before a crash replacement. The actual delay escalates with
732    /// the number of recent crash replacements and is capped by `max_backoff`.
733    pub backoff: Duration,
734    /// Maximum delay before a crash replacement.
735    pub max_backoff: Duration,
736    /// The span `max_restarts` is counted over. `Duration::ZERO` makes the
737    /// budget effectively infinite (nothing is ever in-window), which is why
738    /// daemon config refuses `window_secs: 0` rather than quietly accepting it.
739    pub window: Duration,
740}
741
742impl RestartPolicy {
743    /// A policy with the default crash window. Callers that care about the
744    /// window say so with [`Self::with_window`]; the ones that do not are
745    /// asking for the standard rate limit, not for no limit.
746    pub fn new(max_restarts: u32, backoff: Duration) -> Self {
747        Self {
748            max_restarts,
749            backoff,
750            max_backoff: DEFAULT_MAX_BACKOFF,
751            window: DEFAULT_RESTART_WINDOW,
752        }
753    }
754
755    pub fn with_max_backoff(mut self, max_backoff: Duration) -> Self {
756        self.max_backoff = max_backoff;
757        self
758    }
759
760    pub fn with_window(mut self, window: Duration) -> Self {
761        self.window = window;
762        self
763    }
764
765    /// Calculate the capped exponential delay for the next crash replacement.
766    /// `restart_in_window` is zero for the first replacement after an operator
767    /// action (restart, reload, re-enable) cleared the crash ring, or after all
768    /// older crash replacements have aged out of the window.
769    fn delay_for_restart(&self, restart_in_window: u32) -> Duration {
770        if self.backoff.is_zero() || self.max_backoff.is_zero() {
771            return Duration::ZERO;
772        }
773
774        let mut delay = self.backoff;
775        for _ in 0..restart_in_window {
776            if delay >= self.max_backoff {
777                return self.max_backoff;
778            }
779            delay = delay
780                .checked_mul(10)
781                .unwrap_or(self.max_backoff)
782                .min(self.max_backoff);
783        }
784        delay.min(self.max_backoff)
785    }
786
787    /// The one sentence that explains a budget-exhausted stop, used for both the
788    /// log line and the terminal record so the two cannot drift. It names the
789    /// window because `max_restarts=3` alone reads as a lifetime cap, which is
790    /// exactly what this budget is not.
791    fn budget_exhausted_detail(&self) -> String {
792        format!(
793            "crash budget exhausted: max_restarts={} within window_secs={}",
794            self.max_restarts,
795            self.window.as_secs()
796        )
797    }
798}
799
800impl Default for RestartPolicy {
801    fn default() -> Self {
802        Self {
803            max_restarts: DEFAULT_MAX_RESTARTS,
804            backoff: DEFAULT_BACKOFF,
805            max_backoff: DEFAULT_MAX_BACKOFF,
806            window: DEFAULT_RESTART_WINDOW,
807        }
808    }
809}
810
811#[derive(Debug, Clone, Copy, PartialEq, Eq)]
812struct CrashRestartSchedule {
813    restart_in_window: u32,
814    delay: Duration,
815}
816
817/// Whether the daemon itself will bring this module back after the exit being
818/// handled: it is enabled AND its in-window crash restarts are below the cap.
819///
820/// Takes `&mut` because reading the budget prunes it. Instants that fell out of
821/// the window are dropped here rather than by a timer, so the count is right
822/// the moment somebody asks and no bookkeeping runs for idle modules.
823fn daemon_will_restart(
824    state: &mut SupervisorSnapshot,
825    policy: &RestartPolicy,
826    now: Instant,
827) -> bool {
828    state.enabled && state.crash_restarts_in_window(policy.window, now) < policy.max_restarts
829}
830
831const DEFAULT_HEALTH_CADENCE: Duration = Duration::from_secs(30);
832const DEFAULT_HEALTH_DEADLINE: Duration = Duration::from_secs(5);
833const DEFAULT_HEALTH_FAILURE_THRESHOLD: u32 = 3;
834const MAX_HEALTH_METRICS_BYTES: usize = 16 * 1024;
835
836#[derive(Debug, Clone, Copy, PartialEq, Eq)]
837pub enum HealthAction {
838    Report,
839    Restart,
840    Alert,
841}
842
843impl fmt::Display for HealthAction {
844    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
845        f.write_str(match self {
846            Self::Report => "report",
847            Self::Restart => "restart",
848            Self::Alert => "alert",
849        })
850    }
851}
852
853#[derive(Debug, Clone, PartialEq, Eq)]
854pub struct HealthConfig {
855    /// Optional loopback HTTP endpoint for a managed non-wire process.
856    /// Changing it applies live on rescan; the process protocol changes only
857    /// at its next spawn.
858    pub http: Option<String>,
859    pub cadence: Duration,
860    pub deadline: Duration,
861    pub failure_threshold: u32,
862    pub on_degraded: HealthAction,
863    pub on_failing: HealthAction,
864    pub critical: bool,
865}
866
867impl Default for HealthConfig {
868    fn default() -> Self {
869        Self {
870            http: None,
871            cadence: DEFAULT_HEALTH_CADENCE,
872            deadline: DEFAULT_HEALTH_DEADLINE,
873            failure_threshold: DEFAULT_HEALTH_FAILURE_THRESHOLD,
874            on_degraded: HealthAction::Report,
875            on_failing: HealthAction::Report,
876            critical: false,
877        }
878    }
879}
880
881/// The supervisor's view of one module's health, relayed to clients over
882/// channel-0 and rendered by `ck health`.
883///
884/// THIS TYPE IS WHERE THE ABSENCE MEANINGS ARE CREATED, which is why they are
885/// stated here rather than only at the wire type a consumer reads. A reader can
886/// look up what `None` means; only a writer can silently change it, and the
887/// writer has no reason to go looking at a downstream contract before editing.
888///
889/// `last_probe_ms: None` MEANS NEVER PROBED, not probed-long-ago. It is cleared
890/// back to `None` on re-registration precisely so a respawned module does not
891/// carry its predecessor's timestamp — so an old value and an absent one call for
892/// opposite readings, and anything that defaulted this to a number would make a
893/// never-probed module indistinguishable from one probed at the epoch.
894///
895/// `detail` and `metrics` are `None` when the module published none on this
896/// probe, which does not mean it reported nothing wrong — it is also the shape
897/// when the probe never reached it. `last_probe_ms` is what separates those.
898#[derive(Debug, Clone, PartialEq)]
899pub struct ModuleHealthStatus {
900    pub status: SupervisorHealthStatus,
901    pub last_probe_ms: Option<u64>,
902    pub detail: Option<String>,
903    pub metrics: Option<Value>,
904    pub consecutive_failures: u32,
905    /// Number of replies received after a recurring health probe's deadline.
906    /// Unlike a timeout, every increment proves the module was alive.
907    pub late_answer_count: u64,
908    /// End-to-end latency of the newest late reply, measured from probe start.
909    pub last_late_answer_latency_ms: Option<u64>,
910    pub last_action: Option<String>,
911    /// Set together with `last_action`; the pair moves as one, and both being
912    /// absent means no escalation has ever been taken rather than that the last
913    /// one succeeded.
914    pub last_action_ms: Option<u64>,
915}
916
917impl Default for ModuleHealthStatus {
918    fn default() -> Self {
919        Self {
920            status: SupervisorHealthStatus::Unknown,
921            last_probe_ms: None,
922            detail: None,
923            metrics: None,
924            consecutive_failures: 0,
925            late_answer_count: 0,
926            last_late_answer_latency_ms: None,
927            last_action: None,
928            last_action_ms: None,
929        }
930    }
931}
932
933/// Typed lifecycle state for a supervised module.
934#[derive(Debug, Clone, Copy, PartialEq, Eq)]
935pub enum ModuleState {
936    Starting,
937    Running,
938    Unresponsive,
939    Restarting,
940    Draining,
941    Stopped,
942    Failed,
943    Disabled,
944}
945
946impl fmt::Display for ModuleState {
947    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
948        f.write_str(match self {
949            Self::Starting => "starting",
950            Self::Running => "running",
951            Self::Unresponsive => "unresponsive",
952            Self::Restarting => "restarting",
953            Self::Draining => "draining",
954            Self::Stopped => "stopped",
955            Self::Failed => "failed",
956            Self::Disabled => "disabled",
957        })
958    }
959}
960
961/// Supervisor classification of a child-process exit.
962#[derive(Debug, Clone, Copy, PartialEq, Eq)]
963pub enum ExitKind {
964    Clean,
965    Crash,
966    DeliberateSeverance,
967}
968
969impl From<ExitKind> for TerminalExitKind {
970    fn from(kind: ExitKind) -> Self {
971        match kind {
972            ExitKind::Clean => Self::Clean,
973            ExitKind::Crash => Self::Crash,
974            ExitKind::DeliberateSeverance => Self::DeliberateSeverance,
975        }
976    }
977}
978
979/// Exact process identity retained when a supervised module registers its
980/// connection. PID reuse makes a PID alone insufficient evidence of ownership.
981#[derive(Debug, Clone, Copy, PartialEq, Eq)]
982pub(crate) struct ProcessIdentity {
983    pub(crate) pid: u32,
984    pub(crate) start_time: u64,
985}
986
987/// Last observed child exit, if any.
988#[derive(Debug, Clone, PartialEq, Eq)]
989pub struct ExitReport {
990    pub kind: ExitKind,
991    pub code: Option<i32>,
992    pub signal: Option<i32>,
993    pub at_ms: u64,
994}
995
996/// Point-in-time module status answerable by subc without forwarding to the
997/// module process.
998#[derive(Debug, Clone, PartialEq)]
999pub struct ModuleStatus {
1000    pub module_id: String,
1001    pub state: ModuleState,
1002    pub enabled: bool,
1003    pub process_alive: bool,
1004    pub registration_active: bool,
1005    /// The module's declared wire protocol, carried beside `live` because it is
1006    /// what makes `live` readable: the two fields answer one question together.
1007    /// While a process is alive this is its launch declaration, not a later
1008    /// pending-reload edit. When down it is the configured next launch protocol.
1009    pub protocol: ModuleProtocol,
1010    /// Whether the module is serving, under the strongest definition the daemon
1011    /// can assert for its protocol.
1012    ///
1013    /// A subc module must also be REGISTERED: its process being alive says
1014    /// nothing about whether it can take a request. A `protocol: "none"` module
1015    /// never registers, so that term is dropped and this falls back to "enabled,
1016    /// running, and the process the daemon launched is alive" -- which is all
1017    /// the daemon observes about a process that speaks no subc wire. It stays a
1018    /// `bool` on the wire for compatibility; renderers pair it with `protocol`
1019    /// rather than printing it bare.
1020    pub live: bool,
1021    /// Crash restarts spent INSIDE `restart_window` as of this read. Older
1022    /// restarts have already released their slot, so this count can go down
1023    /// without anybody touching the module.
1024    pub restart_count: u32,
1025    /// Replacement processes spawned over this module's entire supervisor lifetime;
1026    /// unlike `restart_count`, this value is never reset by an operator action
1027    /// and never falls out of a window.
1028    pub lifetime_restarts: u32,
1029    pub spawn_generation: u64,
1030    /// The budget `restart_count` is spent against. Carried alongside the count
1031    /// because the count alone does not say how close the module is to being
1032    /// disabled, and reporting one without the other is what makes an
1033    /// about-to-be-retired module look ordinary.
1034    pub max_restarts: u32,
1035    /// The span `restart_count` is counted over. Carried with the pair above for
1036    /// the same reason they are carried together: "2 of 3" means one thing for a
1037    /// ten-minute window and something else entirely for a lifetime.
1038    pub restart_window: Duration,
1039    /// Effective drain and restart timing policy used by this running module.
1040    /// These values are carried together with the restart budget so status
1041    /// readers can compare configured intent with what the supervisor applied.
1042    pub drain_timeout: Duration,
1043    pub restart_backoff: Duration,
1044    pub restart_max_backoff: Duration,
1045    /// The module's process. On macOS this stays absent while the `ck-subc`
1046    /// launch trampoline is still running in that pid, and appears once the
1047    /// exec acknowledgement confirms the module image has replaced it. Launch
1048    /// time and the supervisor's own hold on the process are unaffected.
1049    pub pid: Option<u32>,
1050    pub spawned_at_ms: Option<u64>,
1051    pub spawned_from: Option<PathBuf>,
1052    pub process_start_time: Option<u64>,
1053    pub last_exit: Option<ExitReport>,
1054    pub health: ModuleHealthStatus,
1055}
1056
1057#[cfg(test)]
1058#[derive(Debug, Clone, Copy, PartialEq)]
1059struct ActorSelectCheckpoint {
1060    generation: u64,
1061    turn: u64,
1062    registered_connection: Option<ConnectionId>,
1063    next_probe_at: Option<Instant>,
1064    wake_after: Option<Duration>,
1065}
1066
1067#[derive(Debug, Clone, PartialEq)]
1068struct SupervisorSnapshot {
1069    /// Counts running-child loop turns, not executor polls of a parked wait.
1070    #[cfg(test)]
1071    actor_turns: u64,
1072    /// Running select after exec confirmation and with no unconsumed registry
1073    /// event. Recording a spawn or starting a loop turn is not this barrier.
1074    #[cfg(test)]
1075    actor_select: Option<ActorSelectCheckpoint>,
1076    state: ModuleState,
1077    enabled: bool,
1078    process_alive: bool,
1079    spawned_protocol: Option<ModuleProtocol>,
1080    spawn_failure: Option<String>,
1081    /// When each crash restart was spent, oldest first. This IS the crash
1082    /// budget: its in-window length is the count an operator sees and the count
1083    /// the restart decision is made against, so there is no second counter that
1084    /// can disagree with it. Bounded by `max_restarts`, and cleared by the same
1085    /// operator actions that used to zero the old lifetime counter.
1086    crash_restarts: VecDeque<Instant>,
1087    lifetime_restarts: u32,
1088    /// Successful child spawns in this daemon incarnation.
1089    ///
1090    /// `lifetime_restarts` was considered and rejected: it starts at zero
1091    /// (line 640), successful initial/operator spawns in `set_running` do not
1092    /// increment it (lines 5264-5274), and crash/deliberate retry bookkeeping
1093    /// increments before a successful replacement exists (lines 604, 3846,
1094    /// and 3921), so a failed spawn can consume it. This counter moves only
1095    /// when a live PID is accepted below.
1096    spawn_generation: u64,
1097    pid: Option<u32>,
1098    #[cfg(target_os = "macos")]
1099    report_ready: Option<Arc<OnceLock<()>>>,
1100    /// Last reaped child, retained after current process facts are cleared.
1101    reaped_pid: Option<u32>,
1102    /// Whether the command-serving supervision loop has a scheduled respawn.
1103    respawn_pending: bool,
1104    /// A second restart is waiting for the replacement already scheduled.
1105    coalesced_restart_pending: bool,
1106    spawned_at_ms: Option<u64>,
1107    spawned_from: Option<PathBuf>,
1108    spawned_file_identity: Option<SpawnedFileIdentity>,
1109    process_start_time: Option<u64>,
1110    deliberate_severance: Option<ProcessIdentity>,
1111    last_exit: Option<ExitReport>,
1112    /// Diagnostic attached to the next drain's terminal record, if any.
1113    drain_disposition_detail: Option<String>,
1114    health: ModuleHealthStatus,
1115    /// Whether the current process was started as a swap candidate and so
1116    /// lives in the module's alternate cgroup. The next swap's candidate takes
1117    /// the other one, so the two processes of a swap never share a cgroup. A
1118    /// plain spawn always uses the primary cgroup.
1119    in_alternate_slot: bool,
1120    /// Whether the current `Draining` state ends in a replacement process
1121    /// (restart, reload, health restart) rather than a stop. Only meaningful
1122    /// while `state` is `Draining`; every entry into that state rewrites it.
1123    /// It is what lets route.open answer the retryable `module_reloading` to a
1124    /// consumer that reaches a still-registered process mid-restart, instead of
1125    /// the `supervisor_not_live` a stop or disable deserves.
1126    draining_to_replace: bool,
1127    /// Whether a configuration update has been applied since the current
1128    /// process was spawned, so that process runs an older spec than the one
1129    /// the supervisor now holds. A queued restart is only coalesced into a
1130    /// fresher process when this is false: a restart requested to pick up a
1131    /// new configuration must not be satisfied by a process that predates it.
1132    configuration_updated_since_spawn: bool,
1133}
1134
1135impl SupervisorSnapshot {
1136    /// The pid that status, provenance and resource readings may report. While
1137    /// the launch trampoline still runs in the pid, reading its executable or
1138    /// resource use would describe `ck-subc`, not the module, so none is
1139    /// reported until the exec acknowledgement confirms the module image.
1140    fn reported_pid(&self) -> Option<u32> {
1141        #[cfg(target_os = "macos")]
1142        if self
1143            .report_ready
1144            .as_ref()
1145            .is_some_and(|ready| ready.get().is_none())
1146        {
1147            return None;
1148        }
1149        self.pid
1150    }
1151
1152    fn starting() -> Self {
1153        Self::new(ModuleState::Starting, true)
1154    }
1155
1156    fn disabled() -> Self {
1157        Self::new(ModuleState::Disabled, false)
1158    }
1159
1160    fn failed() -> Self {
1161        Self::new(ModuleState::Failed, true)
1162    }
1163
1164    /// Crash restarts still inside `window`, having dropped the ones that are
1165    /// not. Pruning on read is what makes the budget a rate: an instant older
1166    /// than the window stops holding a slot the moment anybody counts.
1167    fn crash_restarts_in_window(&mut self, window: Duration, now: Instant) -> u32 {
1168        while let Some(oldest) = self.crash_restarts.front() {
1169            if now.duration_since(*oldest) > window {
1170                self.crash_restarts.pop_front();
1171            } else {
1172                break;
1173            }
1174        }
1175        u32::try_from(self.crash_restarts.len()).unwrap_or(u32::MAX)
1176    }
1177
1178    /// Spend one unit of the crash budget and record the restart in the ledger.
1179    ///
1180    /// The ring is bounded by the cap because more than `max_restarts` in-window
1181    /// instants can never be reached (the caller refuses the restart first), so
1182    /// anything beyond that is an unbounded queue waiting to happen.
1183    fn record_crash_restart(&mut self, policy: &RestartPolicy, now: Instant) {
1184        self.crash_restarts.push_back(now);
1185        while self.crash_restarts.len() > policy.max_restarts as usize {
1186            self.crash_restarts.pop_front();
1187        }
1188        self.lifetime_restarts += 1;
1189    }
1190
1191    /// Reserve one crash-restart slot and calculate the delay before respawning.
1192    /// The count is captured before recording this restart, so the first retry
1193    /// uses the base delay and each later in-window retry escalates once.
1194    fn next_crash_restart(
1195        &mut self,
1196        policy: &RestartPolicy,
1197        now: Instant,
1198    ) -> Option<CrashRestartSchedule> {
1199        let restart_in_window = self.crash_restarts_in_window(policy.window, now);
1200        if restart_in_window >= policy.max_restarts {
1201            return None;
1202        }
1203        self.record_crash_restart(policy, now);
1204        Some(CrashRestartSchedule {
1205            restart_in_window,
1206            delay: policy.delay_for_restart(restart_in_window),
1207        })
1208    }
1209
1210    /// Give the module its full budget back, as an operator restart, reload, or
1211    /// re-enable does. `lifetime_restarts` deliberately does not move: it is the
1212    /// ledger of what actually happened, and an operator action does not unmake
1213    /// the crashes.
1214    fn clear_crash_restarts(&mut self) {
1215        self.crash_restarts.clear();
1216    }
1217
1218    fn new(state: ModuleState, enabled: bool) -> Self {
1219        Self {
1220            #[cfg(test)]
1221            actor_turns: 0,
1222            #[cfg(test)]
1223            actor_select: None,
1224            state,
1225            enabled,
1226            process_alive: false,
1227            spawned_protocol: None,
1228            spawn_failure: None,
1229            crash_restarts: VecDeque::new(),
1230            lifetime_restarts: 0,
1231            spawn_generation: 0,
1232            pid: None,
1233            #[cfg(target_os = "macos")]
1234            report_ready: None,
1235            reaped_pid: None,
1236            respawn_pending: false,
1237            coalesced_restart_pending: false,
1238            spawned_at_ms: None,
1239            spawned_from: None,
1240            spawned_file_identity: None,
1241            process_start_time: None,
1242            deliberate_severance: None,
1243            last_exit: None,
1244            drain_disposition_detail: None,
1245            health: ModuleHealthStatus::default(),
1246            in_alternate_slot: false,
1247            draining_to_replace: false,
1248            configuration_updated_since_spawn: false,
1249        }
1250    }
1251}
1252
1253type SharedSnapshot = Arc<Mutex<SupervisorSnapshot>>;
1254
1255type SpawnSubscriberKey = (ConnectionId, u64);
1256
1257#[derive(Debug)]
1258struct SpawnSubscriber {
1259    version: u8,
1260    frames: mpsc::Sender<Frame>,
1261    /// Tells this subscriber's forwarder that it was dropped for lagging, and
1262    /// from which event. The full frame channel cannot carry that news, so it
1263    /// travels beside it; see `SpawnEventFeed::subscribe`.
1264    lagged: Option<oneshot::Sender<SpawnCursor>>,
1265}
1266
1267#[derive(Debug)]
1268struct SpawnEventState {
1269    daemon_incarnation: String,
1270    seq: u64,
1271    capacity: usize,
1272    live: HashMap<String, LiveSpawn>,
1273    generations: HashMap<String, u64>,
1274    events: VecDeque<SpawnEvent>,
1275    subscribers: HashMap<SpawnSubscriberKey, SpawnSubscriber>,
1276}
1277
1278impl Default for SpawnEventState {
1279    fn default() -> Self {
1280        Self {
1281            daemon_incarnation: "unconfigured".to_string(),
1282            seq: 0,
1283            capacity: SPAWN_EVENT_RING_CAPACITY,
1284            live: HashMap::new(),
1285            generations: HashMap::new(),
1286            events: VecDeque::new(),
1287            subscribers: HashMap::new(),
1288        }
1289    }
1290}
1291
1292#[derive(Debug, Clone, Default)]
1293struct SpawnEventFeed(Arc<Mutex<SpawnEventState>>);
1294
1295#[derive(Debug, Clone, PartialEq, Eq)]
1296pub(crate) enum SpawnSubscribeRefusal {
1297    ForeignIncarnation { current: String },
1298    TooOld { oldest: SpawnCursor },
1299    Frame(String),
1300}
1301
1302impl SpawnEventFeed {
1303    fn configure_incarnation(&self, daemon_incarnation: String) {
1304        let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1305        state.daemon_incarnation = daemon_incarnation;
1306        state.seq = 0;
1307        state.live.clear();
1308        state.generations.clear();
1309        state.events.clear();
1310        state.subscribers.clear();
1311    }
1312
1313    fn cursor(state: &SpawnEventState) -> SpawnCursor {
1314        SpawnCursor {
1315            daemon_incarnation: state.daemon_incarnation.clone(),
1316            seq: state.seq,
1317        }
1318    }
1319
1320    fn snapshot(&self) -> SpawnSnapshot {
1321        let state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1322        let mut live = state.live.values().cloned().collect::<Vec<_>>();
1323        live.sort_by(|left, right| left.module_id.cmp(&right.module_id));
1324        SpawnSnapshot {
1325            cursor: Self::cursor(&state),
1326            ring_bound: state.capacity as u64,
1327            live,
1328        }
1329    }
1330
1331    fn emit_spawned(&self, module_id: &str, pid: u32, spawned_at_ms: u64) -> u64 {
1332        let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1333        let generation = state
1334            .generations
1335            .get(module_id)
1336            .copied()
1337            .unwrap_or(0)
1338            .checked_add(1)
1339            .expect("spawn generation exhausted");
1340        state.generations.insert(module_id.to_string(), generation);
1341        let live = LiveSpawn {
1342            module_id: module_id.to_string(),
1343            spawn_generation: generation,
1344            pid,
1345            spawned_at_ms,
1346        };
1347        state.live.insert(module_id.to_string(), live);
1348        Self::emit_locked(
1349            &mut state,
1350            SpawnEventKind::Spawned,
1351            module_id.to_string(),
1352            generation,
1353            pid,
1354            None,
1355            None,
1356        );
1357        generation
1358    }
1359
1360    fn emit_exited(&self, module_id: &str, exit_code: Option<i32>, exit_signal: Option<i32>) {
1361        let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1362        let Some(live) = state.live.remove(module_id) else {
1363            warn!(
1364                module_id,
1365                "terminal record had no live spawn event identity"
1366            );
1367            return;
1368        };
1369        Self::emit_locked(
1370            &mut state,
1371            SpawnEventKind::Exited,
1372            module_id.to_string(),
1373            live.spawn_generation,
1374            live.pid,
1375            exit_code,
1376            exit_signal,
1377        );
1378    }
1379
1380    /// Report the exit of a process that a swap has already replaced.
1381    ///
1382    /// `emit_exited` removes the module's live entry, which after a swap's
1383    /// cutover describes the promoted candidate, not the old process now
1384    /// exiting. This emits the old generation's exit and leaves the live entry
1385    /// alone unless it still names that generation.
1386    fn emit_superseded_exited(
1387        &self,
1388        module_id: &str,
1389        spawn_generation: u64,
1390        pid: u32,
1391        exit_code: Option<i32>,
1392        exit_signal: Option<i32>,
1393    ) {
1394        let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1395        if state
1396            .live
1397            .get(module_id)
1398            .is_some_and(|live| live.spawn_generation == spawn_generation)
1399        {
1400            state.live.remove(module_id);
1401        }
1402        Self::emit_locked(
1403            &mut state,
1404            SpawnEventKind::Exited,
1405            module_id.to_string(),
1406            spawn_generation,
1407            pid,
1408            exit_code,
1409            exit_signal,
1410        );
1411    }
1412
1413    #[allow(clippy::too_many_arguments)]
1414    fn emit_locked(
1415        state: &mut SpawnEventState,
1416        kind: SpawnEventKind,
1417        module_id: String,
1418        spawn_generation: u64,
1419        pid: u32,
1420        exit_code: Option<i32>,
1421        exit_signal: Option<i32>,
1422    ) {
1423        state.seq = state
1424            .seq
1425            .checked_add(1)
1426            .expect("spawn event sequence exhausted");
1427        let event = SpawnEvent {
1428            cursor: Self::cursor(state),
1429            kind,
1430            module_id,
1431            spawn_generation,
1432            pid,
1433            exit_code,
1434            exit_signal,
1435        };
1436        state.events.push_back(event.clone());
1437        while state.events.len() > state.capacity {
1438            state.events.pop_front();
1439        }
1440        let body = match serde_json::to_vec(&event) {
1441            Ok(body) => body,
1442            Err(error) => {
1443                error!(%error, "failed to serialize supervisor spawn event");
1444                return;
1445            }
1446        };
1447        state.subscribers.retain(|(connection_id, corr), subscriber| {
1448            let frame = Frame::build_with_version(
1449                subscriber.version,
1450                FrameType::StreamData,
1451                control_flags(),
1452                0,
1453                0,
1454                *corr,
1455                body.clone(),
1456            );
1457            match frame {
1458                Ok(frame) => {
1459                    if subscriber.frames.try_send(frame).is_ok() {
1460                        true
1461                    } else {
1462                        warn!(connection_id = connection_id.get(), corr, "dropping lagged supervisor spawn subscriber");
1463                        if let Some(lagged) = subscriber.lagged.take() {
1464                            let _ = lagged.send(event.cursor.clone());
1465                        }
1466                        false
1467                    }
1468                }
1469                Err(error) => {
1470                    warn!(connection_id = connection_id.get(), corr, %error, "dropping supervisor spawn subscriber after frame build failure");
1471                    false
1472                }
1473            }
1474        });
1475    }
1476
1477    fn subscribe(
1478        &self,
1479        connection_id: ConnectionId,
1480        corr: u64,
1481        version: u8,
1482        since: Option<SpawnCursor>,
1483        sink: FrameSink,
1484    ) -> Result<(), SpawnSubscribeRefusal> {
1485        let (frames, mut receiver) = mpsc::channel(SPAWN_SUBSCRIBER_BUFFER);
1486        let (lagged, mut lagged_rx) = oneshot::channel::<SpawnCursor>();
1487        {
1488            let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1489            let replay = if let Some(since) = since {
1490                if since.daemon_incarnation != state.daemon_incarnation {
1491                    return Err(SpawnSubscribeRefusal::ForeignIncarnation {
1492                        current: state.daemon_incarnation.clone(),
1493                    });
1494                }
1495                if let Some(oldest) = state.events.front().map(|event| event.cursor.clone()) {
1496                    if since.seq < oldest.seq.saturating_sub(1) {
1497                        return Err(SpawnSubscribeRefusal::TooOld { oldest });
1498                    }
1499                }
1500                state
1501                    .events
1502                    .iter()
1503                    .filter(|event| event.cursor.seq > since.seq)
1504                    .cloned()
1505                    .collect::<Vec<_>>()
1506            } else {
1507                Vec::new()
1508            };
1509            for event in replay {
1510                let body = serde_json::to_vec(&event)
1511                    .map_err(|error| SpawnSubscribeRefusal::Frame(error.to_string()))?;
1512                let frame = Frame::build_with_version(
1513                    version,
1514                    FrameType::StreamData,
1515                    control_flags(),
1516                    0,
1517                    0,
1518                    corr,
1519                    body,
1520                )
1521                .map_err(|error| SpawnSubscribeRefusal::Frame(error.to_string()))?;
1522                frames
1523                    .try_send(frame)
1524                    .map_err(|error| SpawnSubscribeRefusal::Frame(error.to_string()))?;
1525            }
1526            state.subscribers.insert(
1527                (connection_id, corr),
1528                SpawnSubscriber {
1529                    version,
1530                    frames,
1531                    lagged: Some(lagged),
1532                },
1533            );
1534        }
1535        // The lagged terminal is sent here, by the forwarder, rather than by
1536        // the emitter: at the moment of the drop the subscriber's own channel
1537        // is full, and writing to the connection sink directly from the emitter
1538        // would put the Error AHEAD of the events still queued in that channel
1539        // (and the emitter holds the feed lock, so it cannot await the sink).
1540        // Dropping the subscriber drops the only sender, so `recv` drains every
1541        // queued event and then returns `None`; only then is the Error sent, so
1542        // the client sees each event it can keep, then the reason it was cut.
1543        // Cancel and connection removal drop the oneshot unsent, so they end
1544        // the stream with no Error.
1545        tokio::spawn(async move {
1546            while let Some(frame) = receiver.recv().await {
1547                if sink.send(frame).await.is_err() {
1548                    return;
1549                }
1550            }
1551            let Ok(first_undelivered) = lagged_rx.try_recv() else {
1552                return;
1553            };
1554            match spawn_subscriber_lagged_frame(version, corr, first_undelivered) {
1555                Ok(frame) => {
1556                    let _ = sink.send(frame).await;
1557                }
1558                Err(error) => {
1559                    error!(%error, corr, "failed to build lagged spawn subscriber terminal frame");
1560                }
1561            }
1562        });
1563        Ok(())
1564    }
1565
1566    fn cancel(&self, connection_id: ConnectionId, corr: u64) -> bool {
1567        let Some(subscriber) = self
1568            .0
1569            .lock()
1570            .unwrap_or_else(|p| p.into_inner())
1571            .subscribers
1572            .remove(&(connection_id, corr))
1573        else {
1574            return false;
1575        };
1576        if let Ok(frame) = Frame::build_with_version(
1577            subscriber.version,
1578            FrameType::StreamEnd,
1579            control_flags(),
1580            0,
1581            0,
1582            corr,
1583            Vec::new(),
1584        ) {
1585            tokio::spawn(async move {
1586                let _ = subscriber.frames.send(frame).await;
1587            });
1588        }
1589        true
1590    }
1591
1592    fn remove_connection(&self, connection_id: ConnectionId) {
1593        self.0
1594            .lock()
1595            .unwrap_or_else(|p| p.into_inner())
1596            .subscribers
1597            .retain(|(subscriber_connection, _), _| *subscriber_connection != connection_id);
1598    }
1599
1600    #[cfg(any(test, feature = "test-support"))]
1601    fn set_capacity(&self, capacity: usize) {
1602        self.0.lock().unwrap_or_else(|p| p.into_inner()).capacity = capacity;
1603    }
1604
1605    #[cfg(any(test, feature = "test-support"))]
1606    fn subscriber_count(&self) -> usize {
1607        self.0
1608            .lock()
1609            .unwrap_or_else(|p| p.into_inner())
1610            .subscribers
1611            .len()
1612    }
1613}
1614
1615/// Narrow process-liveness signal published by supervisors and consumed by passive liveness polls.
1616/// The terminal Error a lagged spawn subscriber receives after its queued events.
1617fn spawn_subscriber_lagged_frame(
1618    version: u8,
1619    corr: u64,
1620    first_undelivered: SpawnCursor,
1621) -> Result<Frame, String> {
1622    let body = serde_json::to_vec(&subc_protocol::ErrorBody {
1623        code: SPAWN_SUBSCRIBER_LAGGED_CODE.to_string(),
1624        message: "spawn subscriber fell behind and was dropped; resubscribe from the last cursor received"
1625            .to_string(),
1626        detail: Some(serde_json::json!({
1627            "first_undelivered_cursor": first_undelivered
1628        })),
1629    })
1630    .map_err(|error| error.to_string())?;
1631    Frame::build_with_version(version, FrameType::Error, control_flags(), 0, 0, corr, body)
1632        .map_err(|error| error.to_string())
1633}
1634
1635pub trait ModuleProcessLiveness: Send + Sync {
1636    fn process_live(&self, module_id: &str) -> Option<bool>;
1637
1638    /// Whether the supervisor is replacing this module's process right now: an
1639    /// operator restart or reload, a health restart, or a crash respawn whose
1640    /// backoff is running. A module in that state is not live, but a consumer
1641    /// refused now should retry shortly rather than treat the target as gone.
1642    /// Stopped, failed, and disabled modules are not replacing.
1643    fn process_replacing(&self, _module_id: &str) -> bool {
1644        false
1645    }
1646}
1647
1648/// Shared process-liveness registry keyed by supervised `module_id`.
1649#[derive(Debug, Clone, Default)]
1650pub struct SupervisorProcessLiveness {
1651    snapshots: Arc<Mutex<HashMap<String, SharedSnapshot>>>,
1652}
1653
1654impl SupervisorProcessLiveness {
1655    pub fn new() -> Self {
1656        Self::default()
1657    }
1658
1659    fn track(&self, module_id: String, snapshot: SharedSnapshot) {
1660        let mut snapshots = self
1661            .snapshots
1662            .lock()
1663            .unwrap_or_else(|poisoned| poisoned.into_inner());
1664        snapshots.insert(module_id, snapshot);
1665    }
1666
1667    fn untrack_if_current(&self, module_id: &str, snapshot: &SharedSnapshot) {
1668        let mut snapshots = self
1669            .snapshots
1670            .lock()
1671            .unwrap_or_else(|poisoned| poisoned.into_inner());
1672        let is_current = snapshots
1673            .get(module_id)
1674            .map(|tracked| Arc::ptr_eq(tracked, snapshot))
1675            .unwrap_or(false);
1676        if is_current {
1677            snapshots.remove(module_id);
1678        }
1679    }
1680}
1681
1682impl ModuleProcessLiveness for SupervisorProcessLiveness {
1683    fn process_live(&self, module_id: &str) -> Option<bool> {
1684        let snapshot = {
1685            let snapshots = self
1686                .snapshots
1687                .lock()
1688                .unwrap_or_else(|poisoned| poisoned.into_inner());
1689            snapshots.get(module_id).cloned()
1690        }?;
1691        let snapshot = snapshot
1692            .lock()
1693            .unwrap_or_else(|poisoned| poisoned.into_inner());
1694        Some(snapshot.state == ModuleState::Running && snapshot.process_alive)
1695    }
1696
1697    fn process_replacing(&self, module_id: &str) -> bool {
1698        let Some(snapshot) = self
1699            .snapshots
1700            .lock()
1701            .unwrap_or_else(|poisoned| poisoned.into_inner())
1702            .get(module_id)
1703            .cloned()
1704        else {
1705            return false;
1706        };
1707        let snapshot = snapshot
1708            .lock()
1709            .unwrap_or_else(|poisoned| poisoned.into_inner());
1710        snapshot.enabled
1711            && match snapshot.state {
1712                ModuleState::Restarting => true,
1713                ModuleState::Draining => snapshot.draining_to_replace,
1714                ModuleState::Starting
1715                | ModuleState::Running
1716                | ModuleState::Unresponsive
1717                | ModuleState::Stopped
1718                | ModuleState::Failed
1719                | ModuleState::Disabled => false,
1720            }
1721    }
1722}
1723
1724#[cfg(test)]
1725#[derive(Debug, Default)]
1726struct ReloadExitRecordGate {
1727    reached: tokio::sync::Notify,
1728    resume: tokio::sync::Notify,
1729}
1730
1731#[derive(Debug, Clone, Copy)]
1732enum RespawnKind {
1733    Spawn,
1734    Reload,
1735}
1736
1737#[derive(Debug, Clone, Copy)]
1738struct PendingRespawn {
1739    deadline: Instant,
1740    kind: RespawnKind,
1741}
1742
1743type ReloadReply = oneshot::Sender<Result<(), SuperviseError>>;
1744
1745#[derive(Debug, Clone)]
1746struct SupervisorRuntimeConfig {
1747    /// Restart operations hand their backoff to the loop, which keeps serving commands.
1748    scheduled_respawn: Arc<Mutex<Option<PendingRespawn>>>,
1749    /// A reload acknowledges completion only after its replacement registers.
1750    deferred_reload_reply: Arc<Mutex<Option<ReloadReply>>>,
1751    restart_policy: RestartPolicy,
1752    /// This module's RESOLVED drain budget: per-module config when present,
1753    /// else `default_drain_timeout`.
1754    drain_timeout: Duration,
1755    /// Shared with the status handle so the attested value changes atomically
1756    /// when a rescan updates the running drain policy.
1757    effective_drain_timeout: Arc<Mutex<Duration>>,
1758    /// The supervisor-wide fallback, kept so a configuration update that
1759    /// REMOVES the per-module override can re-resolve to it.
1760    default_drain_timeout: Duration,
1761    health: HealthConfig,
1762    connection_file_path: Option<PathBuf>,
1763    capture_logs_dir: Option<PathBuf>,
1764    forwarding: Option<Arc<ForwardingTable>>,
1765    /// The shared handle, so every spawn path (initial, restart, reload) records the
1766    /// reserved-module launch nonce the HELLO verifier checks against.
1767    supervisor_handle: Option<SupervisorHandle>,
1768    /// This module's stderr tail, shared with the [`SupervisedModule`] that answers
1769    /// status queries.
1770    ///
1771    /// One ring per module, held across every respawn. The lines explaining an exit
1772    /// are written BEFORE that exit, so a ring recreated per process would be empty
1773    /// exactly when it is asked for.
1774    stderr_ring: Arc<Mutex<StderrRing>>,
1775    terminal_ring: Arc<Mutex<TerminalRing>>,
1776    spawn_events: SpawnEventFeed,
1777    child_roster: ChildRoster,
1778    #[cfg(target_os = "linux")]
1779    cgroup_placement: Option<subc_cgroup::Placement>,
1780    #[cfg(test)]
1781    test_seed_stale_facts_before_enable_spawn: bool,
1782    #[cfg(test)]
1783    test_reload_exit_record_gate: Option<Arc<ReloadExitRecordGate>>,
1784}
1785
1786#[derive(Debug, Clone, PartialEq, Eq)]
1787struct SupervisedConfiguration {
1788    spec: ModuleSpec,
1789    health: HealthConfig,
1790}
1791
1792/// Shared daemon lookup table for supervised module handles.
1793///
1794/// Shared by clone between the [`Supervisor`] (which spawns processes) and the
1795/// channel-0 control handler (which verifies HELLOs and consumer route opens), so
1796/// launch nonces recorded at spawn are checked by the same daemon instance.
1797#[derive(Debug, Clone, Default)]
1798pub struct SupervisorHandle {
1799    modules: Arc<Mutex<HashMap<String, SupervisedModule>>>,
1800    /// Module ids the supervisor has taken on. An id is added BEFORE the
1801    /// module's first process is spawned and removed only when the module
1802    /// leaves the roster (see [`Self::retire`]), so it is always a superset of
1803    /// the keys of `modules`.
1804    ///
1805    /// `modules` cannot answer "is this module configured?" on its own: a
1806    /// [`SupervisedModule`] only exists once its process has been spawned, and
1807    /// a fast child can connect, register, sync its scopes and ask about them
1808    /// before the supervisor has inserted it. Answering "not configured" in that
1809    /// gap makes scope admission refuse with the terminal "will never sync"
1810    /// instead of the retryable "has not synced yet".
1811    configured_ids: Arc<Mutex<HashSet<String>>>,
1812    spawn_events: SpawnEventFeed,
1813    /// The current expected launch nonce for each reserved module_id. Set when the
1814    /// supervisor spawns the reserved module; checked when a HELLO claims that id. A
1815    /// non-reserved module never has an entry here and is never nonce-checked.
1816    /// Reserved module ids and the nonce that authorizes their next HELLO.
1817    /// `None` means RESERVED WITH NO LEGITIMATE HOLDER — a reserved module that
1818    /// has never been spawned (e.g. configured `enabled: false`) — and refuses
1819    /// every HELLO. Before this was expressible, a reserved-but-never-spawned id
1820    /// had NO entry and admitted anyone: the reservation protected the nonce
1821    /// holder, not the NAME (found live by CKCRED's canary probe registering
1822    /// against a reserved scratch id).
1823    reserved_nonces: Arc<Mutex<HashMap<String, Option<String>>>>,
1824    /// Module ids removed by an executed rescan and the unix-millisecond removal time.
1825    ///
1826    /// This is deliberately in-memory only: subc is state-free across daemon
1827    /// restarts, and the tombstone only explains the hours-after-removal window
1828    /// while this executing daemon is still alive. Do not persist it in a store.
1829    removal_tombstones: Arc<Mutex<HashMap<String, u64>>>,
1830    /// The current launch nonce for every supervised spawn. This is separate from
1831    /// reserved_nonces because consumer route.open attestation applies to all spawned
1832    /// modules, while HELLO id-squatting protection remains opt-in via `reserved`.
1833    spawn_nonces: Arc<Mutex<HashMap<String, String>>>,
1834    /// Reserved namespace prefixes mapped to the supervised owner module whose
1835    /// current spawn nonce authorizes HELLO claims below the prefix.
1836    ///
1837    /// Per §2.6 this is not a same-user security barrier: a same-user process can
1838    /// read the key file and launch nonce env. Like exact reserved ids, it prevents
1839    /// accidental collisions and lower-trust processes from squatting protected
1840    /// namespaces.
1841    reserved_prefix_owners: Arc<Mutex<HashMap<String, String>>>,
1842    /// Blue/green swaps in progress, by module id. An entry exists from just
1843    /// before the candidate process is spawned until the swap has failed, or
1844    /// has cut over and the old process is gone. While it exists, HELLO for the
1845    /// id is gated on the swap token (see [`Self::swap_hello_admission`]) and
1846    /// consumer attestation accepts both processes' nonces.
1847    swaps: Arc<Mutex<HashMap<String, OpenSwap>>>,
1848    /// Told when a swap promotes its candidate; see [`SwapPromotionObserver`].
1849    promotion_observer: PromotionObserverSlot,
1850    /// Serializes module-set reconciliation with operator lifecycle commands. Without
1851    /// this daemon-wide ordering, a rescan could retire or update a module while a
1852    /// concurrent reload still held its old handle and launch specification.
1853    operation_lock: Arc<AsyncMutex<()>>,
1854}
1855
1856/// Told when a swap has promoted its candidate to be the module's active
1857/// registration.
1858///
1859/// An ordinary HELLO runs the control plane's registration side effects (the
1860/// capability cache, the deny census, the requirement recompute) as it
1861/// registers. A swap candidate's HELLO does not, because it is not routable;
1862/// promotion is when those must run instead, and promotion happens in the
1863/// supervisor, which has no other way into the control handler.
1864pub(crate) trait SwapPromotionObserver: Send + Sync {
1865    fn swap_promoted(&self, registration: &crate::registry::ModuleRegistration);
1866}
1867
1868/// The installed [`SwapPromotionObserver`], held weakly: the observer (the
1869/// control handler) owns this handle, so a strong reference back would be a
1870/// cycle that keeps both alive.
1871#[derive(Clone, Default)]
1872struct PromotionObserverSlot(Arc<Mutex<Option<std::sync::Weak<dyn SwapPromotionObserver>>>>);
1873
1874impl fmt::Debug for PromotionObserverSlot {
1875    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1876        f.write_str("PromotionObserverSlot")
1877    }
1878}
1879
1880/// The nonces of one open swap.
1881#[derive(Debug, Clone)]
1882struct OpenSwap {
1883    /// The launch nonce minted for the candidate process. It is the swap
1884    /// token: the only thing that admits a HELLO into the candidate slot.
1885    candidate_nonce: String,
1886    /// The incumbent's launch nonce, captured when the swap opened. It is kept
1887    /// here because cutover moves the module's recorded spawn nonce to the
1888    /// candidate while the incumbent is still draining and its consumers are
1889    /// still attesting with this one.
1890    incumbent_nonce: Option<String>,
1891    /// Set once a HELLO has been admitted with the swap token, so the token
1892    /// admits one registration and cannot be replayed after cutover empties
1893    /// the candidate slot.
1894    candidate_admitted: bool,
1895}
1896
1897/// What the swap gate says about a HELLO. See
1898/// [`SupervisorHandle::swap_hello_admission`].
1899#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1900pub(crate) enum SwapHelloAdmission {
1901    /// No swap is open for the id (or the HELLO carries the incumbent's own
1902    /// nonce); the ordinary gates decide.
1903    NotSwapping,
1904    /// The HELLO carries the swap token: register it into the candidate slot.
1905    Candidate,
1906    /// A swap is open and the HELLO carries a nonce the supervisor did not
1907    /// mint for this id, no nonce, or a token already used.
1908    Refused,
1909}
1910
1911#[derive(Debug, Clone, PartialEq, Eq)]
1912pub(crate) enum ReservedHelloRejection {
1913    Exact {
1914        module_id: String,
1915    },
1916    Prefix {
1917        prefix: String,
1918        owner_module_id: String,
1919    },
1920}
1921
1922impl SupervisorHandle {
1923    pub fn new() -> Self {
1924        Self::default()
1925    }
1926
1927    pub(crate) fn spawn_snapshot(&self) -> SpawnSnapshot {
1928        self.spawn_events.snapshot()
1929    }
1930
1931    pub(crate) fn subscribe_spawns(
1932        &self,
1933        connection_id: ConnectionId,
1934        corr: u64,
1935        version: u8,
1936        since: Option<SpawnCursor>,
1937        sink: FrameSink,
1938    ) -> Result<(), SpawnSubscribeRefusal> {
1939        self.spawn_events
1940            .subscribe(connection_id, corr, version, since, sink)
1941    }
1942
1943    pub(crate) fn cancel_spawn_subscription(&self, connection_id: ConnectionId, corr: u64) -> bool {
1944        self.spawn_events.cancel(connection_id, corr)
1945    }
1946
1947    pub(crate) fn remove_spawn_subscribers(&self, connection_id: ConnectionId) {
1948        self.spawn_events.remove_connection(connection_id);
1949    }
1950
1951    #[cfg(any(test, feature = "test-support"))]
1952    pub fn set_spawn_event_capacity_for_test(&self, capacity: usize) {
1953        assert!(capacity > 0, "spawn event capacity must be non-zero");
1954        self.spawn_events.set_capacity(capacity);
1955    }
1956
1957    #[cfg(any(test, feature = "test-support"))]
1958    pub fn spawn_subscriber_count_for_test(&self) -> usize {
1959        self.spawn_events.subscriber_count()
1960    }
1961
1962    /// Record the launch nonce from a supervised spawn, replacing any prior nonce so
1963    /// a respawn invalidates stale consumer identities.
1964    pub fn set_spawn_nonce(&self, module_id: &str, nonce: String) {
1965        self.spawn_nonces
1966            .lock()
1967            .unwrap_or_else(|poisoned| poisoned.into_inner())
1968            .insert(module_id.to_string(), nonce);
1969    }
1970
1971    /// Record the launch nonce expected from the next HELLO for a reserved module,
1972    /// replacing any prior nonce (a respawn invalidates the previous one).
1973    pub fn set_reserved_nonce(&self, module_id: &str, nonce: String) {
1974        self.reserved_nonces
1975            .lock()
1976            .unwrap_or_else(|poisoned| poisoned.into_inner())
1977            .insert(module_id.to_string(), Some(nonce));
1978    }
1979
1980    /// Record namespace prefixes owned by a supervised module.
1981    pub fn set_reserved_prefixes(&self, owner_module_id: &str, prefixes: &[String]) {
1982        let mut owners = self
1983            .reserved_prefix_owners
1984            .lock()
1985            .unwrap_or_else(|poisoned| poisoned.into_inner());
1986        owners.retain(|_, owner| owner != owner_module_id);
1987        for prefix in prefixes {
1988            owners.insert(prefix.clone(), owner_module_id.to_string());
1989        }
1990    }
1991
1992    /// The launch nonce most recently minted for a module's spawn, if any.
1993    #[cfg(test)]
1994    pub(crate) fn spawn_nonce(&self, module_id: &str) -> Option<String> {
1995        self.spawn_nonces
1996            .lock()
1997            .unwrap_or_else(|poisoned| poisoned.into_inner())
1998            .get(module_id)
1999            .cloned()
2000    }
2001
2002    fn apply_identity_configuration(&self, spec: &ModuleSpec) {
2003        self.set_reserved_prefixes(&spec.module_id, &spec.reserved_prefixes);
2004        let spawn_nonce = self
2005            .spawn_nonces
2006            .lock()
2007            .unwrap_or_else(|poisoned| poisoned.into_inner())
2008            .get(&spec.module_id)
2009            .cloned();
2010        let mut reserved_nonces = self
2011            .reserved_nonces
2012            .lock()
2013            .unwrap_or_else(|poisoned| poisoned.into_inner());
2014        if spec.reserved {
2015            // `None` (no spawn nonce minted) is INSERTED, not skipped: a
2016            // reserved name whose module has never spawned has no legitimate
2017            // holder, and the entry's absence is what used to leave the name
2018            // open to the first claimant.
2019            reserved_nonces.insert(spec.module_id.clone(), spawn_nonce);
2020        }
2021        drop(reserved_nonces);
2022        // A later unreserved declaration must not silently unreserve an id that
2023        // was retained after its reserved configuration was removed. The explicit
2024        // release ceremony is the only operation that retires that gate.
2025        self.removal_tombstones
2026            .lock()
2027            .unwrap_or_else(|poisoned| poisoned.into_inner())
2028            .remove(&spec.module_id);
2029    }
2030
2031    /// Whether a HELLO claiming `module_id` is authorized. An exact reserved id is
2032    /// authorized only by its expected nonce; otherwise a matching reserved prefix
2033    /// is authorized by the owner module's current spawn nonce. Non-reserved ids
2034    /// with no matching prefix are always authorized.
2035    pub fn reserved_hello_authorized(&self, module_id: &str, presented: Option<&str>) -> bool {
2036        self.reserved_hello_rejection(module_id, presented)
2037            .is_none()
2038    }
2039
2040    pub(crate) fn reserved_hello_rejection(
2041        &self,
2042        module_id: &str,
2043        presented: Option<&str>,
2044    ) -> Option<ReservedHelloRejection> {
2045        let nonces = self
2046            .reserved_nonces
2047            .lock()
2048            .unwrap_or_else(|poisoned| poisoned.into_inner());
2049        if let Some(expected) = nonces.get(module_id) {
2050            // `None` = reserved with no legitimate holder: refuse every
2051            // presentation, because no process can hold a nonce that was never
2052            // minted. Only a real minted nonce admits, in constant time.
2053            let authorized = match expected {
2054                Some(expected) => {
2055                    presented.is_some_and(|p| constant_time_eq(expected.as_bytes(), p.as_bytes()))
2056                }
2057                None => false,
2058            };
2059            if authorized {
2060                return None;
2061            }
2062            return Some(ReservedHelloRejection::Exact {
2063                module_id: module_id.to_string(),
2064            });
2065        }
2066        drop(nonces);
2067
2068        let matched_prefix = self
2069            .reserved_prefix_owners
2070            .lock()
2071            .unwrap_or_else(|poisoned| poisoned.into_inner())
2072            .iter()
2073            .filter(|(prefix, _)| module_id.starts_with(prefix.as_str()))
2074            .max_by_key(|(prefix, _)| prefix.len())
2075            .map(|(prefix, owner)| (prefix.clone(), owner.clone()));
2076        let (prefix, owner_module_id) = matched_prefix?;
2077
2078        let authorized = presented.is_some_and(|presented| {
2079            self.spawn_nonces
2080                .lock()
2081                .unwrap_or_else(|poisoned| poisoned.into_inner())
2082                .get(&owner_module_id)
2083                .is_some_and(|expected| constant_time_eq(expected.as_bytes(), presented.as_bytes()))
2084                // While the owner is being swapped, children started by
2085                // either of its two processes hold that process's nonce.
2086                || self.swap_nonce_matches(&owner_module_id, presented)
2087        });
2088        if authorized {
2089            None
2090        } else {
2091            Some(ReservedHelloRejection::Prefix {
2092                prefix,
2093                owner_module_id,
2094            })
2095        }
2096    }
2097
2098    /// Whether a consumer connection proved it came from a daemon-spawned module.
2099    ///
2100    /// Absence of an expected spawn nonce is a hard failure: consumer_identity is
2101    /// accepted only for module ids the supervisor has spawned.
2102    pub fn spawned_consumer_authorized(&self, module_id: &str, presented: &str) -> bool {
2103        if presented.is_empty() {
2104            return false;
2105        }
2106        let nonces = self
2107            .spawn_nonces
2108            .lock()
2109            .unwrap_or_else(|poisoned| poisoned.into_inner());
2110        let current = nonces
2111            .get(module_id)
2112            .is_some_and(|expected| constant_time_eq(expected.as_bytes(), presented.as_bytes()));
2113        drop(nonces);
2114        // During a swap two processes of the module are alive, and a consumer
2115        // started by either one presents that process's nonce. Accepting only
2116        // the recorded one would fail the incumbent's consumers for the whole
2117        // overlap once cutover moves the record to the candidate.
2118        current || self.swap_nonce_matches(module_id, presented)
2119    }
2120
2121    /// Whether `presented` is either nonce of an open swap for `module_id`.
2122    fn swap_nonce_matches(&self, module_id: &str, presented: &str) -> bool {
2123        let swaps = self
2124            .swaps
2125            .lock()
2126            .unwrap_or_else(|poisoned| poisoned.into_inner());
2127        swaps.get(module_id).is_some_and(|swap| {
2128            constant_time_eq(swap.candidate_nonce.as_bytes(), presented.as_bytes())
2129                || swap.incumbent_nonce.as_deref().is_some_and(|incumbent| {
2130                    constant_time_eq(incumbent.as_bytes(), presented.as_bytes())
2131                })
2132        })
2133    }
2134
2135    /// Open a swap for `module_id` with the candidate's freshly minted nonce.
2136    /// Called before the candidate process exists.
2137    pub(crate) fn open_swap(&self, module_id: &str, candidate_nonce: String) {
2138        let incumbent_nonce = self
2139            .spawn_nonces
2140            .lock()
2141            .unwrap_or_else(|poisoned| poisoned.into_inner())
2142            .get(module_id)
2143            .cloned();
2144        self.swaps
2145            .lock()
2146            .unwrap_or_else(|poisoned| poisoned.into_inner())
2147            .insert(
2148                module_id.to_string(),
2149                OpenSwap {
2150                    candidate_nonce,
2151                    incumbent_nonce,
2152                    candidate_admitted: false,
2153                },
2154            );
2155    }
2156
2157    /// Close the swap for `module_id`, releasing whichever nonce is no longer
2158    /// the module's recorded one.
2159    pub(crate) fn close_swap(&self, module_id: &str) {
2160        self.swaps
2161            .lock()
2162            .unwrap_or_else(|poisoned| poisoned.into_inner())
2163            .remove(module_id);
2164    }
2165
2166    /// Install the observer told about swap promotions, replacing any earlier
2167    /// one.
2168    pub(crate) fn set_swap_promotion_observer(
2169        &self,
2170        observer: std::sync::Weak<dyn SwapPromotionObserver>,
2171    ) {
2172        *self
2173            .promotion_observer
2174            .0
2175            .lock()
2176            .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(observer);
2177    }
2178
2179    /// Tell the installed observer, if it is still alive, that a swap promoted
2180    /// `registration`.
2181    fn notify_swap_promoted(&self, registration: &crate::registry::ModuleRegistration) {
2182        let observer = self
2183            .promotion_observer
2184            .0
2185            .lock()
2186            .unwrap_or_else(|poisoned| poisoned.into_inner())
2187            .as_ref()
2188            .and_then(std::sync::Weak::upgrade);
2189        if let Some(observer) = observer {
2190            observer.swap_promoted(registration);
2191        }
2192    }
2193
2194    /// Whether a swap is open for `module_id`.
2195    pub(crate) fn swap_open(&self, module_id: &str) -> bool {
2196        self.swaps
2197            .lock()
2198            .unwrap_or_else(|poisoned| poisoned.into_inner())
2199            .contains_key(module_id)
2200    }
2201
2202    /// Make the candidate's nonce the module's recorded spawn nonce, as a plain
2203    /// respawn would, once cutover has made the candidate the module's process.
2204    /// The swap stays open so the incumbent's nonce keeps attesting until the
2205    /// incumbent has drained and exited.
2206    fn promote_swap_nonce(&self, module_id: &str, reserved: bool) {
2207        let candidate_nonce = self
2208            .swaps
2209            .lock()
2210            .unwrap_or_else(|poisoned| poisoned.into_inner())
2211            .get(module_id)
2212            .map(|swap| swap.candidate_nonce.clone());
2213        let Some(nonce) = candidate_nonce else {
2214            return;
2215        };
2216        self.set_spawn_nonce(module_id, nonce.clone());
2217        if reserved {
2218            self.set_reserved_nonce(module_id, nonce);
2219        }
2220    }
2221
2222    /// The swap gate for a HELLO claiming `module_id`.
2223    ///
2224    /// This runs BEFORE the reserved-module gate. A reserved module's candidate
2225    /// presents the candidate nonce, which the reserved gate (holding the
2226    /// incumbent's nonce) would refuse as `reserved_module` before swap
2227    /// admission was ever reached. And it applies to unreserved ids too: for an
2228    /// unreserved id the only thing that ever stopped a second process claiming
2229    /// a live id was the `duplicate_module_id` refusal, which is exactly the
2230    /// refusal a swap lifts for its candidate.
2231    ///
2232    /// The incumbent's own nonce falls through to the ordinary gates, which
2233    /// treat it as they always have (a live incumbent is refused as a
2234    /// duplicate). Anything else while a swap is open is refused, including an
2235    /// absent nonce.
2236    pub(crate) fn swap_hello_admission(
2237        &self,
2238        module_id: &str,
2239        presented: Option<&str>,
2240    ) -> SwapHelloAdmission {
2241        let swaps = self
2242            .swaps
2243            .lock()
2244            .unwrap_or_else(|poisoned| poisoned.into_inner());
2245        let Some(swap) = swaps.get(module_id) else {
2246            return SwapHelloAdmission::NotSwapping;
2247        };
2248        let Some(presented) = presented else {
2249            return SwapHelloAdmission::Refused;
2250        };
2251        if constant_time_eq(swap.candidate_nonce.as_bytes(), presented.as_bytes()) {
2252            return if swap.candidate_admitted {
2253                SwapHelloAdmission::Refused
2254            } else {
2255                SwapHelloAdmission::Candidate
2256            };
2257        }
2258        if swap
2259            .incumbent_nonce
2260            .as_deref()
2261            .is_some_and(|incumbent| constant_time_eq(incumbent.as_bytes(), presented.as_bytes()))
2262        {
2263            return SwapHelloAdmission::NotSwapping;
2264        }
2265        SwapHelloAdmission::Refused
2266    }
2267
2268    /// Record that the swap token has registered a candidate, so it admits no
2269    /// second HELLO.
2270    pub(crate) fn mark_swap_candidate_admitted(&self, module_id: &str) {
2271        if let Some(swap) = self
2272            .swaps
2273            .lock()
2274            .unwrap_or_else(|poisoned| poisoned.into_inner())
2275            .get_mut(module_id)
2276        {
2277            swap.candidate_admitted = true;
2278        }
2279    }
2280
2281    /// Test/support lookup for the current launch nonce of a supervised spawn.
2282    pub fn spawn_launch_nonce_for(&self, module_id: &str) -> Option<String> {
2283        self.spawn_nonces
2284            .lock()
2285            .unwrap_or_else(|poisoned| poisoned.into_inner())
2286            .get(module_id)
2287            .cloned()
2288    }
2289
2290    /// Test/support lookup for the HELLO-gating nonce of a reserved module.
2291    pub fn reserved_launch_nonce_for(&self, module_id: &str) -> Option<String> {
2292        self.reserved_nonces
2293            .lock()
2294            .unwrap_or_else(|poisoned| poisoned.into_inner())
2295            .get(module_id)
2296            .cloned()
2297            .flatten()
2298    }
2299
2300    pub fn insert(&self, module: SupervisedModule) -> Option<SupervisedModule> {
2301        // Normally already marked before the process was spawned; marking here
2302        // too keeps `configured_ids` a superset of the roster for any caller
2303        // that inserts a module directly.
2304        self.mark_configured(module.module_id());
2305        let mut modules = self
2306            .modules
2307            .lock()
2308            .unwrap_or_else(|poisoned| poisoned.into_inner());
2309        modules.insert(module.module_id().to_string(), module)
2310    }
2311
2312    /// Record that the supervisor has taken on `module_id`. Called before the
2313    /// module's first process is spawned, so that by the time that process can
2314    /// register, [`Self::is_configured`] already answers true.
2315    fn mark_configured(&self, module_id: &str) {
2316        self.configured_ids
2317            .lock()
2318            .unwrap_or_else(|poisoned| poisoned.into_inner())
2319            .insert(module_id.to_string());
2320    }
2321
2322    /// Undo [`Self::mark_configured`] for a module whose first spawn failed
2323    /// before it was ever put on the roster. A module already on the roster
2324    /// keeps its mark: only [`Self::retire`] takes a rostered module off.
2325    fn unmark_configured_unless_rostered(&self, module_id: &str) {
2326        let modules = self
2327            .modules
2328            .lock()
2329            .unwrap_or_else(|poisoned| poisoned.into_inner());
2330        if !modules.contains_key(module_id) {
2331            self.configured_ids
2332                .lock()
2333                .unwrap_or_else(|poisoned| poisoned.into_inner())
2334                .remove(module_id);
2335        }
2336    }
2337
2338    /// Whether `module_id` is a module this daemon supervises: on the roster,
2339    /// or about to be (its process is being spawned right now).
2340    ///
2341    /// This, not `get(..).is_some()`, is what "the owner is configured" means
2342    /// for scopes: a supervised module's process can register and sync before
2343    /// [`Self::get`] can return it, and in that window it is still a module
2344    /// that will sync, not one that never will.
2345    pub(crate) fn is_configured(&self, module_id: &str) -> bool {
2346        self.configured_ids
2347            .lock()
2348            .unwrap_or_else(|poisoned| poisoned.into_inner())
2349            .contains(module_id)
2350    }
2351
2352    pub fn get(&self, module_id: &str) -> Option<SupervisedModule> {
2353        let modules = self
2354            .modules
2355            .lock()
2356            .unwrap_or_else(|poisoned| poisoned.into_inner());
2357        modules.get(module_id).cloned()
2358    }
2359
2360    pub(crate) fn record_late_health_answer(
2361        &self,
2362        module_id: &str,
2363        latency_ms: u64,
2364    ) -> Result<bool, SuperviseError> {
2365        let Some(module) = self.get(module_id) else {
2366            return Ok(false);
2367        };
2368        update_snapshot(&module.inner.snapshot, Some(module_id), |state| {
2369            state.health.late_answer_count = state.health.late_answer_count.saturating_add(1);
2370            state.health.last_late_answer_latency_ms = Some(latency_ms);
2371            // A late answer is an answer: the module served the probe, just past
2372            // the deadline. Leaving the miss streak in place while logging
2373            // "proves the module is alive" is how a CPU-starved module that
2374            // answers every probe a few seconds late still marches to the
2375            // threshold and gets killed — the exact kill class `NoAnswer` is
2376            // excluded from `is_proof_of_death` to prevent. Slow-but-answering
2377            // is degradation, and degradation reports; it does not restart.
2378            state.health.consecutive_failures = 0;
2379        })?;
2380        Ok(true)
2381    }
2382
2383    /// Arm the one-shot marker for the module process that this caller
2384    /// deliberately initiated severance against. Generic connection teardown
2385    /// must not call this:
2386    /// a surviving process would otherwise retain an exemption for a later
2387    /// genuine crash.
2388    pub fn record_deliberate_severance(&self, module_id: &str) -> Result<bool, SuperviseError> {
2389        let Some(module) = self.get(module_id) else {
2390            return Ok(false);
2391        };
2392        let snapshot = lock_snapshot(&module.inner.snapshot)?;
2393        let Some((pid, start_time)) = snapshot.pid.zip(snapshot.process_start_time) else {
2394            return Ok(false);
2395        };
2396        drop(snapshot);
2397        module.record_deliberate_severance(ProcessIdentity { pid, start_time })
2398    }
2399
2400    pub fn list(&self) -> Vec<SupervisedModule> {
2401        let modules = self
2402            .modules
2403            .lock()
2404            .unwrap_or_else(|poisoned| poisoned.into_inner());
2405        let mut modules = modules.values().cloned().collect::<Vec<_>>();
2406        modules.sort_by(|left, right| left.module_id().cmp(right.module_id()));
2407        modules
2408    }
2409
2410    pub(crate) fn retire(&self, module_id: &str) -> Option<SupervisedModule> {
2411        self.spawn_nonces
2412            .lock()
2413            .unwrap_or_else(|poisoned| poisoned.into_inner())
2414            .remove(module_id);
2415        self.close_swap(module_id);
2416        let mut reserved_nonces = self
2417            .reserved_nonces
2418            .lock()
2419            .unwrap_or_else(|poisoned| poisoned.into_inner());
2420        if reserved_nonces.contains_key(module_id) {
2421            // The old nonce must die with the removed process, but the exact-id
2422            // gate remains until an operator explicitly releases it.
2423            reserved_nonces.insert(module_id.to_string(), None);
2424        }
2425        drop(reserved_nonces);
2426        self.reserved_prefix_owners
2427            .lock()
2428            .unwrap_or_else(|poisoned| poisoned.into_inner())
2429            .retain(|_, owner| owner != module_id);
2430        let removed = self
2431            .modules
2432            .lock()
2433            .unwrap_or_else(|poisoned| poisoned.into_inner())
2434            .remove(module_id);
2435        self.configured_ids
2436            .lock()
2437            .unwrap_or_else(|poisoned| poisoned.into_inner())
2438            .remove(module_id);
2439        removed
2440    }
2441
2442    /// Remember a module removed by a non-preview rescan so route.open can
2443    /// distinguish that intentional removal from an unknown id.
2444    pub(crate) fn record_rescan_removal(&self, module_id: &str) {
2445        self.removal_tombstones
2446            .lock()
2447            .unwrap_or_else(|poisoned| poisoned.into_inner())
2448            .insert(module_id.to_string(), unix_ms_now());
2449    }
2450
2451    /// Return how long ago a rescan removed this module in milliseconds.
2452    pub(crate) fn removal_tombstone_age_ms(&self, module_id: &str) -> Option<u64> {
2453        self.removal_tombstones
2454            .lock()
2455            .unwrap_or_else(|poisoned| poisoned.into_inner())
2456            .get(module_id)
2457            .copied()
2458            .map(|removed_at_ms| unix_ms_now().saturating_sub(removed_at_ms))
2459    }
2460
2461    /// Retire a reserved-id gate only after its module has left supervision.
2462    ///
2463    /// A retained gate has no live nonce (`None`), so releasing any other entry
2464    /// would weaken a currently configured or otherwise active reservation.
2465    pub(crate) fn release_retained_reserved_gate(&self, module_id: &str) -> bool {
2466        if self.get(module_id).is_some() {
2467            return false;
2468        }
2469        let mut reserved_nonces = self
2470            .reserved_nonces
2471            .lock()
2472            .unwrap_or_else(|poisoned| poisoned.into_inner());
2473        if !matches!(reserved_nonces.get(module_id), Some(None)) {
2474            return false;
2475        }
2476        reserved_nonces.remove(module_id);
2477        true
2478    }
2479
2480    pub(crate) fn operation_lock(&self) -> Arc<AsyncMutex<()>> {
2481        Arc::clone(&self.operation_lock)
2482    }
2483}
2484
2485/// Process supervisor for subc-owned singleton modules.
2486#[derive(Debug, Clone)]
2487pub struct Supervisor {
2488    registry: Arc<Registry>,
2489    restart_policy: RestartPolicy,
2490    drain_timeout: Duration,
2491    connection_file_path: Option<PathBuf>,
2492    capture_logs_dir: Option<PathBuf>,
2493    forwarding: Option<Arc<ForwardingTable>>,
2494    process_liveness: Arc<SupervisorProcessLiveness>,
2495    supervisor_handle: Option<SupervisorHandle>,
2496    health: HealthConfig,
2497    daemon_start_clock: crate::clock::StartClock,
2498    terminal_journal: Option<Arc<crate::terminal_journal::TerminalJournal>>,
2499    spawn_events: SpawnEventFeed,
2500    provenance_probe: ExecutableIdentityProbe,
2501    /// Every process spawned through this supervisor (and its clones) and not
2502    /// yet reaped, so daemon shutdown can end them.
2503    child_roster: ChildRoster,
2504    #[cfg(target_os = "linux")]
2505    cgroup_placement: Option<subc_cgroup::Placement>,
2506    #[cfg(test)]
2507    test_after_first_spawn: AfterFirstSpawnHook,
2508}
2509
2510/// Test-only hook run on the path that takes on a new module, right after its
2511/// first `spawn_child` returns (the process exists and could already be
2512/// registering) and before that process is handed to the module's supervise
2513/// loop and put on the roster. Lets a test observe what a fast child would see
2514/// in that window without racing a real one.
2515#[cfg(test)]
2516#[derive(Clone, Default)]
2517struct AfterFirstSpawnHook(Option<AfterFirstSpawnFn>);
2518
2519#[cfg(test)]
2520type AfterFirstSpawnFn = Arc<dyn Fn(&str) + Send + Sync>;
2521
2522#[cfg(test)]
2523impl fmt::Debug for AfterFirstSpawnHook {
2524    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2525        f.write_str("AfterFirstSpawnHook")
2526    }
2527}
2528
2529#[cfg(test)]
2530impl AfterFirstSpawnHook {
2531    fn run(&self, module_id: &str) {
2532        if let Some(hook) = &self.0 {
2533            hook(module_id);
2534        }
2535    }
2536}
2537
2538impl Supervisor {
2539    #[cfg(test)]
2540    pub(crate) fn new_for_test(registry: Arc<Registry>, policy: RestartPolicy) -> Self {
2541        let supervisor = Self::new(registry, policy);
2542        #[cfg(target_os = "macos")]
2543        let supervisor = supervisor.with_privacy_trampoline(test_privacy_trampoline());
2544        supervisor
2545    }
2546    /// Verify the trampoline once when configured. Missing private OS support
2547    /// refuses every macOS launch by name but does not stop the daemon's control
2548    /// server. Embedders must explicitly provide a binary with the subc-os hidden
2549    /// entry point; the library must not exec an arbitrary hosting program.
2550    pub fn with_privacy_trampoline(self, path: impl Into<PathBuf>) -> Self {
2551        let path = path.into();
2552        #[cfg(target_os = "macos")]
2553        {
2554            let result = probe_privacy_trampoline(&path).map(|()| path);
2555            if let Err(cause) = &result {
2556                error!(%cause, "macOS privacy identity unavailable; supervised launches will refuse");
2557            }
2558            self.child_roster.set_privacy_trampoline(result);
2559        }
2560        #[cfg(not(target_os = "macos"))]
2561        let _ = path;
2562        self
2563    }
2564    /// The first step of an announced daemon shutdown, before the notice and
2565    /// before any connection is closed.
2566    ///
2567    /// Sets the daemon-shutdown flag first: from here on no module is
2568    /// respawned (crash restart, operator restart, or swap), and every child
2569    /// exit is recorded as `daemon_shutdown` rather than as a crash, whether
2570    /// the module exits on the EOF this shutdown gives it or is signalled by a
2571    /// service manager that kills the whole cgroup. Then writes the journal's
2572    /// shutdown marker, which records the instant and closes this daemon
2573    /// incarnation's stretch of the journal.
2574    #[cfg(unix)]
2575    pub(crate) fn begin_daemon_shutdown(&self) {
2576        self.child_roster.close();
2577        if let Some(journal) = &self.terminal_journal {
2578            journal.stamp_shutdown();
2579        }
2580    }
2581
2582    /// Announce a cut while established connections can still carry replies.
2583    /// These budgets promise notice and a bounded wait, not child completion;
2584    /// they are local policy, not an estimate of launchd's unknown kill ceiling.
2585    #[cfg(unix)]
2586    pub(crate) async fn drain_for_daemon_shutdown(&self) -> Result<(), SuperviseError> {
2587        const NOTICE_BUDGET: Duration = Duration::from_millis(500);
2588        const DRAIN_BUDGET: Duration = Duration::from_secs(2);
2589        let Some(forwarding) = &self.forwarding else {
2590            return Ok(());
2591        };
2592        let module_ids = forwarding
2593            .begin_daemon_drain()
2594            .map_err(SuperviseError::Forwarding)?;
2595        let deadline_ms =
2596            unix_ms_now().saturating_add((NOTICE_BUDGET + DRAIN_BUDGET).as_millis() as u64);
2597        let mut notices = tokio::task::JoinSet::new();
2598        let mut drains = Vec::new();
2599        for module_id in module_ids {
2600            let Some(target) = forwarding
2601                .begin_module_drain(&module_id, RouteCloseReason::Restart)
2602                .map_err(SuperviseError::Forwarding)?
2603            else {
2604                continue;
2605            };
2606            let routes = forwarding
2607                .endpoint_routes(target.endpoint)
2608                .map_err(SuperviseError::Forwarding)?;
2609            // Restart allows deployed consumers to reopen after the new daemon
2610            // appears. The wire reason stays `restart`; what tells a daemon cut
2611            // apart from a module restart afterwards is the terminal record
2612            // itself, whose disposition is `daemon_shutdown` for every exit
2613            // observed once `begin_daemon_shutdown` has run.
2614            let command = serde_json::to_vec(&ModuleControlCommand::Draining {
2615                reason: RouteCloseReason::Restart,
2616                deadline_ms,
2617            })
2618            .expect("module draining serializes");
2619            let mut recipients = vec![(target.sink.clone(), target.negotiated_ver, command)];
2620            let mut clients: Vec<(crate::forwarding::GoodbyeTarget, Vec<u16>)> = Vec::new();
2621            for route in routes {
2622                let client = route.goodbye_target;
2623                if let Some((_, channels)) = clients
2624                    .iter_mut()
2625                    .find(|(existing, _)| existing.connection_id == client.connection_id)
2626                {
2627                    channels.push(client.channel);
2628                } else {
2629                    let channel = client.channel;
2630                    clients.push((client, vec![channel]));
2631                }
2632            }
2633            for (client, mut channels) in clients {
2634                channels.sort_unstable();
2635                channels.dedup();
2636                let closing = serde_json::to_vec(&ClientControlPush::RouteClosing {
2637                    module_id: module_id.clone(),
2638                    channels,
2639                    reason: RouteCloseReason::Restart,
2640                })
2641                .expect("route closing serializes");
2642                recipients.push((client.sink, client.negotiated_ver, closing));
2643            }
2644            for (sink, version, body) in recipients {
2645                notices.spawn(async move {
2646                    let frame = Frame::build_with_version(
2647                        version,
2648                        FrameType::Push,
2649                        control_flags(),
2650                        0,
2651                        0,
2652                        0,
2653                        body,
2654                    )
2655                    .expect("bounded lifecycle notice frame builds");
2656                    sink.send_flushed(frame).await
2657                });
2658            }
2659            let gauges = declared_busy_gauges(&self.registry, &module_id)?;
2660            drains.push((module_id, target.endpoint, gauges));
2661        }
2662        // A quiet forwarding table is not proof that queued notices reached the
2663        // socket. Wait for writer flush acknowledgements before testing quiescence.
2664        let notice_deadline = Instant::now() + NOTICE_BUDGET;
2665        while let Ok(Some(result)) = timeout_at(notice_deadline, notices.join_next()).await {
2666            if !matches!(result, Ok(Ok(()))) {
2667                warn!(?result, "daemon shutdown notice delivery failed");
2668            }
2669        }
2670        notices.abort_all();
2671        let deadline = Instant::now() + DRAIN_BUDGET;
2672        let mut waits = tokio::task::JoinSet::new();
2673        for (module_id, endpoint, gauges) in drains {
2674            let forwarding = Arc::clone(forwarding);
2675            let mut runtime = self.runtime_config();
2676            runtime.health.cadence = Duration::from_millis(100);
2677            waits.spawn(async move {
2678                wait_for_forwarding_quiescence(
2679                    &forwarding,
2680                    &module_id,
2681                    &runtime,
2682                    endpoint,
2683                    deadline,
2684                    &gauges,
2685                    DrainScope::Active,
2686                )
2687                .await
2688            });
2689        }
2690        while let Ok(Some(result)) = timeout_at(deadline, waits.join_next()).await {
2691            if !matches!(result, Ok(Ok(true))) {
2692                warn!(?result, "daemon shutdown drain did not reach quiescence");
2693            }
2694        }
2695        Ok(())
2696    }
2697
2698    /// The last step of an announced daemon shutdown, after the notice and the
2699    /// drain: send every registered module a module GOODBYE, the same planned
2700    /// stop signal `ck module stop` gives, then close every connection so each
2701    /// subc module sees EOF and starts its own teardown, then end every
2702    /// supervised child that has not exited
2703    /// by its own deadline (its drain budget, capped). Modules lead their own
2704    /// process groups, so a
2705    /// service manager's group kill no longer reaches them; without this a
2706    /// child that does not stop on EOF (every `protocol: "none"` child, which
2707    /// has no connection) would outlive the daemon. Every wait is bounded (see
2708    /// `child_roster`), and `escalate` resolving (a second SIGTERM) cuts them.
2709    #[cfg(unix)]
2710    pub(crate) async fn end_children_for_daemon_shutdown(
2711        &self,
2712        already_escalated: bool,
2713        escalate: impl std::future::Future<Output = ()>,
2714    ) {
2715        tokio::pin!(escalate);
2716        let mut escalated = already_escalated;
2717        if let Some(forwarding) = &self.forwarding {
2718            let reason = CloseReason::new(
2719                "daemon_shutdown",
2720                "the daemon is exiting after its shutdown notice and drain",
2721            );
2722            if escalated {
2723                // The operator asked to stop waiting: queue the GOODBYEs but
2724                // do not wait for them to be written.
2725                send_module_goodbyes_for_daemon_shutdown(forwarding, &reason, false).await;
2726            } else {
2727                tokio::select! {
2728                    biased;
2729                    _ = escalate.as_mut() => {
2730                        info!("second SIGTERM: abandoning module GOODBYE delivery");
2731                        escalated = true;
2732                    }
2733                    _ = send_module_goodbyes_for_daemon_shutdown(forwarding, &reason, true) => {}
2734                }
2735            }
2736            let closed = forwarding.close_all_connections(&reason);
2737            debug!(closed, "closed established connections for daemon shutdown");
2738        }
2739        // If the second SIGTERM arrived during GOODBYE delivery, `escalate` has
2740        // already completed and must not be polled again; the child shutdown
2741        // wait is told it is escalated and gets a future that never fires.
2742        let escalated_here = escalated && !already_escalated;
2743        let remaining_escalate = async move {
2744            if escalated_here {
2745                std::future::pending::<()>().await;
2746            } else {
2747                escalate.await;
2748            }
2749        };
2750        crate::child_roster::end_children_for_daemon_shutdown(
2751            &self.child_roster,
2752            escalated,
2753            remaining_escalate,
2754        )
2755        .await;
2756    }
2757
2758    pub fn new(registry: Arc<Registry>, restart_policy: RestartPolicy) -> Self {
2759        Self {
2760            registry,
2761            restart_policy,
2762            drain_timeout: DEFAULT_DRAIN_TIMEOUT,
2763            connection_file_path: None,
2764            capture_logs_dir: None,
2765            forwarding: None,
2766            process_liveness: Arc::new(SupervisorProcessLiveness::default()),
2767            supervisor_handle: None,
2768            health: HealthConfig::default(),
2769            daemon_start_clock: crate::clock::StartClock::capture(),
2770            terminal_journal: None,
2771            spawn_events: SpawnEventFeed::default(),
2772            provenance_probe: ExecutableIdentityProbe::default(),
2773            child_roster: ChildRoster::default(),
2774            #[cfg(target_os = "linux")]
2775            cgroup_placement: None,
2776            #[cfg(test)]
2777            test_after_first_spawn: AfterFirstSpawnHook::default(),
2778        }
2779    }
2780
2781    pub fn with_drain_timeout(mut self, drain_timeout: Duration) -> Self {
2782        self.drain_timeout = drain_timeout;
2783        self
2784    }
2785
2786    pub fn with_process_liveness(
2787        mut self,
2788        process_liveness: Arc<SupervisorProcessLiveness>,
2789    ) -> Self {
2790        self.process_liveness = process_liveness;
2791        self
2792    }
2793
2794    pub fn with_connection_file_path(mut self, connection_file_path: impl Into<PathBuf>) -> Self {
2795        self.connection_file_path = Some(connection_file_path.into());
2796        self
2797    }
2798
2799    /// Enables daemon-owned capture files for supervised stdout and stderr.
2800    pub fn with_capture_logs_dir(mut self, logs_dir: impl Into<PathBuf>) -> Self {
2801        self.capture_logs_dir = Some(logs_dir.into());
2802        self
2803    }
2804
2805    /// Names this daemon lifetime in spawn events, independently of whether a
2806    /// terminal journal is configured.
2807    pub fn with_daemon_incarnation(self, daemon_incarnation: String) -> Self {
2808        // A millisecond start stamp can repeat after clock rollback or a rapid
2809        // restart. Use the connection file's random daemon_id instead: it already
2810        // identifies this daemon lifetime independently of the wall clock.
2811        self.spawn_events.configure_incarnation(daemon_incarnation);
2812        self
2813    }
2814
2815    /// Enables best-effort history shared by every supervised module. Without
2816    /// it, terminal history is kept only in each module's in-memory ring.
2817    pub fn with_terminal_journal(self, path: PathBuf, daemon_incarnation: String) -> Self {
2818        let mut this = self.with_daemon_incarnation(daemon_incarnation.clone());
2819        this.terminal_journal = Some(Arc::new(crate::terminal_journal::TerminalJournal::open(
2820            path,
2821            daemon_incarnation,
2822        )));
2823        this
2824    }
2825
2826    pub fn with_forwarding(mut self, forwarding: Arc<ForwardingTable>) -> Self {
2827        self.forwarding = Some(forwarding);
2828        self
2829    }
2830
2831    pub fn with_handle(mut self, supervisor_handle: SupervisorHandle) -> Self {
2832        self.spawn_events = supervisor_handle.spawn_events.clone();
2833        self.supervisor_handle = Some(supervisor_handle);
2834        self
2835    }
2836
2837    pub fn with_health_config(mut self, health: HealthConfig) -> Self {
2838        self.health = health;
2839        self
2840    }
2841
2842    /// Keeps a record of every live child at `path`, rewritten on each spawn and
2843    /// reap, for the orphan sweep a later daemon runs at boot. Without it no
2844    /// record is kept.
2845    pub fn with_live_children_record(self, path: impl Into<PathBuf>) -> Self {
2846        self.child_roster.record_to(path.into());
2847        self
2848    }
2849
2850    #[cfg(target_os = "linux")]
2851    pub fn with_cgroup_placement(
2852        mut self,
2853        cgroup_placement: Option<subc_cgroup::Placement>,
2854    ) -> Self {
2855        self.cgroup_placement = cgroup_placement;
2856        self
2857    }
2858
2859    /// Spawn `spec.program` and start monitoring it.
2860    ///
2861    /// The child is expected to parse `--subc <connection-file-path>`, read the
2862    /// TCP+key connection file, authenticate to the already-running listener, and
2863    /// register with channel-0 `HELLO` using `spec.module_id` as its manifest id.
2864    pub fn spawn(&self, spec: ModuleSpec) -> Result<SupervisedModule, SuperviseError> {
2865        validate_spec(&spec)?;
2866        self.establish_identity(&spec);
2867
2868        let runtime = self.runtime_config();
2869        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
2870        let spawned = spawn_child(
2871            &spec,
2872            runtime.connection_file_path.as_deref(),
2873            self.supervisor_handle.as_ref(),
2874            &runtime.stderr_ring,
2875            runtime.capture_logs_dir.as_deref(),
2876            &runtime.child_roster,
2877            #[cfg(target_os = "linux")]
2878            runtime.cgroup_placement.as_ref(),
2879        );
2880        #[cfg(test)]
2881        self.test_after_first_spawn.run(&spec.module_id);
2882        let child = match spawned {
2883            Ok(child) => child,
2884            Err(err) => {
2885                // Unlike the configured paths, a failed `spawn` leaves nothing
2886                // on the roster, so the module must not stay marked configured.
2887                self.abandon_unrostered(&spec.module_id);
2888                return Err(err);
2889            }
2890        };
2891        self.mark_first_process_running(&spec, &runtime, &snapshot, &child)?;
2892
2893        Ok(self.supervised_module(spec, runtime, snapshot, Some(child)))
2894    }
2895
2896    /// Make `spec`'s module count as configured, with its identity gates
2897    /// (reserved id, reserved prefixes) in place, BEFORE any process of it
2898    /// exists.
2899    ///
2900    /// Every path that takes on a new module calls this before `spawn_child`.
2901    /// The order is the point: the child can connect, register, sync its
2902    /// scopes and ask about them as soon as it is spawned, and the module is
2903    /// only put on the roster after `spawn_child` returns. Were the mark set
2904    /// with the roster entry, a fast child would see its own owner reported
2905    /// as not configured, and a scoped `route.open` in that window would be
2906    /// refused as terminal `scope_not_live` ("will never sync") instead of
2907    /// retryable `scope_not_synced`.
2908    fn establish_identity(&self, spec: &ModuleSpec) {
2909        if let Some(supervisor_handle) = &self.supervisor_handle {
2910            supervisor_handle.apply_identity_configuration(spec);
2911            supervisor_handle.mark_configured(&spec.module_id);
2912        }
2913    }
2914
2915    /// Take back [`Self::establish_identity`]'s configured mark when the
2916    /// module will not be put on the roster after all.
2917    fn abandon_unrostered(&self, module_id: &str) {
2918        if let Some(supervisor_handle) = &self.supervisor_handle {
2919            supervisor_handle.unmark_configured_unless_rostered(module_id);
2920        }
2921    }
2922
2923    /// Record a freshly spawned first process as running. On failure the
2924    /// module never reaches the roster, so its configured mark is taken back.
2925    fn mark_first_process_running(
2926        &self,
2927        spec: &ModuleSpec,
2928        runtime: &SupervisorRuntimeConfig,
2929        snapshot: &SharedSnapshot,
2930        child: &SupervisedChild,
2931    ) -> Result<(), SuperviseError> {
2932        if let Err(err) = set_running(snapshot, child, &spec.module_id, &runtime.spawn_events) {
2933            self.abandon_unrostered(&spec.module_id);
2934            return Err(err);
2935        }
2936        self.process_liveness
2937            .track(spec.module_id.clone(), Arc::clone(snapshot));
2938        Ok(())
2939    }
2940
2941    /// Start supervising a module declared in daemon configuration.
2942    ///
2943    /// Unlike [`Self::spawn`], this records disabled modules and immediate spawn
2944    /// failures in the supervisor handle so operator-facing `supervisor.list`
2945    /// reflects every configured module while daemon startup continues.
2946    pub fn supervise_configured(
2947        &self,
2948        spec: ModuleSpec,
2949        enabled: bool,
2950    ) -> Result<SupervisedModule, SuperviseError> {
2951        validate_spec(&spec)?;
2952        self.establish_identity(&spec);
2953
2954        let runtime = self.runtime_config();
2955        if !enabled {
2956            let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::disabled()));
2957            return Ok(self.supervised_module(spec, runtime, snapshot, None));
2958        }
2959
2960        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
2961        let spawned = spawn_child(
2962            &spec,
2963            runtime.connection_file_path.as_deref(),
2964            self.supervisor_handle.as_ref(),
2965            &runtime.stderr_ring,
2966            runtime.capture_logs_dir.as_deref(),
2967            &runtime.child_roster,
2968            #[cfg(target_os = "linux")]
2969            runtime.cgroup_placement.as_ref(),
2970        );
2971        #[cfg(test)]
2972        self.test_after_first_spawn.run(&spec.module_id);
2973        match spawned {
2974            Ok(child) => {
2975                self.mark_first_process_running(&spec, &runtime, &snapshot, &child)?;
2976                Ok(self.supervised_module(spec, runtime, snapshot, Some(child)))
2977            }
2978            Err(err) => {
2979                error!(
2980                    module_id = %spec.module_id,
2981                    program = %spec.program.display(),
2982                    error = %err,
2983                    "configured module failed to spawn; marking failed and continuing"
2984                );
2985                let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::failed()));
2986                Ok(self.supervised_module(spec, runtime, snapshot, None))
2987            }
2988        }
2989    }
2990
2991    /// Supervise a configured module with its own health, drain, and crash
2992    /// budget. The restart policy is per-module because the config file is:
2993    /// `modules.<id>.restart` resolves to a full policy at parse time, and a
2994    /// module that is expensive to restart should not be forced onto the same
2995    /// budget as one that is cheap.
2996    pub fn supervise_configured_with_health(
2997        &self,
2998        spec: ModuleSpec,
2999        enabled: bool,
3000        health: HealthConfig,
3001        drain_timeout_ms: Option<u64>,
3002        restart_policy: RestartPolicy,
3003    ) -> Result<SupervisedModule, SuperviseError> {
3004        validate_spec(&spec)?;
3005        self.establish_identity(&spec);
3006
3007        let mut runtime = self.runtime_config();
3008        runtime.health = health.clone();
3009        runtime.restart_policy = restart_policy;
3010        if let Some(ms) = drain_timeout_ms {
3011            runtime.drain_timeout = Duration::from_millis(ms);
3012            *runtime
3013                .effective_drain_timeout
3014                .lock()
3015                .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.drain_timeout;
3016        }
3017        if !enabled {
3018            let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::disabled()));
3019            return Ok(self.supervised_module(spec, runtime, snapshot, None));
3020        }
3021
3022        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
3023        let spawned = spawn_child(
3024            &spec,
3025            runtime.connection_file_path.as_deref(),
3026            self.supervisor_handle.as_ref(),
3027            &runtime.stderr_ring,
3028            runtime.capture_logs_dir.as_deref(),
3029            &runtime.child_roster,
3030            #[cfg(target_os = "linux")]
3031            runtime.cgroup_placement.as_ref(),
3032        );
3033        #[cfg(test)]
3034        self.test_after_first_spawn.run(&spec.module_id);
3035        match spawned {
3036            Ok(child) => {
3037                self.mark_first_process_running(&spec, &runtime, &snapshot, &child)?;
3038                Ok(self.supervised_module(spec, runtime, snapshot, Some(child)))
3039            }
3040            Err(err) => {
3041                if health.critical {
3042                    error!(
3043                        module_id = %spec.module_id,
3044                        program = %spec.program.display(),
3045                        error = %err,
3046                        "critical configured module failed to spawn; marking failed and alerting"
3047                    );
3048                } else {
3049                    error!(
3050                        module_id = %spec.module_id,
3051                        program = %spec.program.display(),
3052                        error = %err,
3053                        "configured module failed to spawn; marking failed and continuing"
3054                    );
3055                }
3056                let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::failed()));
3057                Ok(self.supervised_module(spec, runtime, snapshot, None))
3058            }
3059        }
3060    }
3061
3062    fn runtime_config(&self) -> SupervisorRuntimeConfig {
3063        let effective_drain_timeout = Arc::new(Mutex::new(self.drain_timeout));
3064        SupervisorRuntimeConfig {
3065            scheduled_respawn: Arc::default(),
3066            deferred_reload_reply: Arc::default(),
3067            restart_policy: self.restart_policy,
3068            drain_timeout: self.drain_timeout,
3069            // Shared with this module's roster copy: daemon shutdown waits on
3070            // each child for the module's own drain budget, as resolved now.
3071            child_roster: self
3072                .child_roster
3073                .for_module(Arc::clone(&effective_drain_timeout)),
3074            effective_drain_timeout,
3075            default_drain_timeout: self.drain_timeout,
3076            health: self.health.clone(),
3077            connection_file_path: self.connection_file_path.clone(),
3078            capture_logs_dir: self.capture_logs_dir.clone(),
3079            forwarding: self.forwarding.clone(),
3080            supervisor_handle: self.supervisor_handle.clone(),
3081            stderr_ring: Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default()))),
3082            terminal_ring: Arc::new(Mutex::new(
3083                TerminalRing::new(
3084                    TerminalRingConfig::default(),
3085                    self.daemon_start_clock.started_at_ms(),
3086                )
3087                .with_start_clock(self.daemon_start_clock)
3088                .with_journal(self.terminal_journal.clone())
3089                .with_daemon_shutdown(self.child_roster.shutdown_flag()),
3090            )),
3091            spawn_events: self.spawn_events.clone(),
3092            #[cfg(target_os = "linux")]
3093            cgroup_placement: self.cgroup_placement.clone(),
3094            #[cfg(test)]
3095            test_seed_stale_facts_before_enable_spawn: false,
3096            #[cfg(test)]
3097            test_reload_exit_record_gate: None,
3098        }
3099    }
3100
3101    fn supervised_module(
3102        &self,
3103        spec: ModuleSpec,
3104        runtime: SupervisorRuntimeConfig,
3105        snapshot: SharedSnapshot,
3106        child: Option<SupervisedChild>,
3107    ) -> SupervisedModule {
3108        let configuration = Arc::new(Mutex::new(SupervisedConfiguration {
3109            spec: spec.clone(),
3110            health: runtime.health.clone(),
3111        }));
3112        let stderr_ring = Arc::clone(&runtime.stderr_ring);
3113        let terminal_ring = Arc::clone(&runtime.terminal_ring);
3114        // The module's OWN policy, which may be its per-module config rather than
3115        // the supervisor-wide one; status must report the budget the supervise
3116        // loop actually enforces.
3117        let restart_policy = runtime.restart_policy;
3118        let effective_drain_timeout = Arc::clone(&runtime.effective_drain_timeout);
3119        let (tx, rx) = mpsc::channel(4);
3120        let monitor = tokio::spawn(supervise_loop(
3121            spec.clone(),
3122            runtime,
3123            Arc::clone(&self.registry),
3124            Arc::clone(&self.process_liveness),
3125            Arc::clone(&snapshot),
3126            child,
3127            rx,
3128        ));
3129
3130        let module_id = spec.module_id.clone();
3131        let module = SupervisedModule {
3132            inner: Arc::new(SupervisedModuleInner {
3133                module_id: module_id.clone(),
3134                registry: Arc::clone(&self.registry),
3135                snapshot,
3136                configuration,
3137                stderr_ring,
3138                terminal_ring,
3139                commands: tx,
3140                monitor: Mutex::new(Some(monitor)),
3141                restart_policy,
3142                effective_drain_timeout,
3143                provenance_probe: self.provenance_probe.clone(),
3144            }),
3145        };
3146        // The identity gates and the configured mark were set by
3147        // `establish_identity` before any process was spawned; only the roster
3148        // entry waits for the module handle, which needs the spawned child.
3149        if let Some(supervisor_handle) = &self.supervisor_handle {
3150            supervisor_handle.insert(module.clone());
3151        }
3152        module
3153    }
3154}
3155
3156impl Default for Supervisor {
3157    fn default() -> Self {
3158        Self::new(Arc::new(Registry::default()), RestartPolicy::default())
3159    }
3160}
3161
3162/// Handle to one supervised child process.
3163#[derive(Clone)]
3164pub struct SupervisedModule {
3165    inner: Arc<SupervisedModuleInner>,
3166}
3167
3168struct SupervisedModuleInner {
3169    module_id: String,
3170    registry: Arc<Registry>,
3171    snapshot: SharedSnapshot,
3172    configuration: Arc<Mutex<SupervisedConfiguration>>,
3173    stderr_ring: Arc<Mutex<StderrRing>>,
3174    terminal_ring: Arc<Mutex<TerminalRing>>,
3175    commands: mpsc::Sender<SupervisorCommand>,
3176    monitor: Mutex<Option<JoinHandle<()>>>,
3177    /// Copied from the supervisor's runtime config at spawn so `status()` can
3178    /// report the restart budget without reaching back into the supervisor. The
3179    /// policy is fixed for the process's lifetime, so a copy cannot drift.
3180    restart_policy: RestartPolicy,
3181    effective_drain_timeout: Arc<Mutex<Duration>>,
3182    provenance_probe: ExecutableIdentityProbe,
3183}
3184
3185impl fmt::Debug for SupervisedModule {
3186    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3187        f.debug_struct("SupervisedModule")
3188            .field("module_id", &self.inner.module_id)
3189            .field("status", &self.status())
3190            .finish_non_exhaustive()
3191    }
3192}
3193
3194impl SupervisedModule {
3195    pub fn module_id(&self) -> &str {
3196        &self.inner.module_id
3197    }
3198
3199    /// Test-only: put one probe miss on the streak, the way
3200    /// `handle_health_probe_failure` does, so tests can assert what a later
3201    /// event does to the streak without driving the whole probe loop.
3202    #[cfg(test)]
3203    pub(crate) fn record_health_probe_failure_for_test(
3204        &self,
3205        detail: &str,
3206    ) -> Result<(), SuperviseError> {
3207        update_snapshot(&self.inner.snapshot, Some(&self.inner.module_id), |state| {
3208            state.health.consecutive_failures = state.health.consecutive_failures.saturating_add(1);
3209            state.health.detail = Some(detail.to_string());
3210        })
3211    }
3212
3213    pub fn state(&self) -> Result<ModuleState, SuperviseError> {
3214        Ok(lock_snapshot(&self.inner.snapshot)?.state)
3215    }
3216
3217    pub(crate) fn registration_end_reason(
3218        &self,
3219    ) -> Result<Option<RegistrationEndReason>, SuperviseError> {
3220        let snapshot = lock_snapshot(&self.inner.snapshot)?;
3221        Ok(match snapshot.state {
3222            ModuleState::Draining if snapshot.draining_to_replace => {
3223                Some(RegistrationEndReason::SupervisorRestart)
3224            }
3225            ModuleState::Draining => Some(RegistrationEndReason::SupervisorStop),
3226            ModuleState::Restarting if snapshot.draining_to_replace => {
3227                Some(RegistrationEndReason::SupervisorRestart)
3228            }
3229            _ => None,
3230        })
3231    }
3232
3233    /// The module's retained stderr, newest lines last.
3234    ///
3235    /// Deliberately NOT on [`Self::status`]: a bounded tail is kilobytes per
3236    /// module, `supervisor.list` renders every module, and putting it in the
3237    /// shared snapshot would make each status read carry a payload almost nobody
3238    /// asked for. Callers that want the text ask for it.
3239    pub fn stderr_tail(
3240        &self,
3241        max_lines: Option<usize>,
3242        max_bytes: Option<usize>,
3243    ) -> StderrTailSnapshot {
3244        self.inner
3245            .stderr_ring
3246            .lock()
3247            .unwrap_or_else(|poisoned| poisoned.into_inner())
3248            .snapshot(max_lines, max_bytes)
3249    }
3250
3251    /// The module's bounded terminal history, oldest retained exit first.
3252    ///
3253    /// The daemon-start stamp distinguishes a quiet supervisor from a replacement
3254    /// daemon whose in-memory history was necessarily reset.
3255    pub fn terminal_history(&self) -> TerminalHistorySnapshot {
3256        self.inner
3257            .terminal_ring
3258            .lock()
3259            .unwrap_or_else(|poisoned| poisoned.into_inner())
3260            .snapshot()
3261    }
3262
3263    /// Retained observations from the current ring and all journal generations.
3264    ///
3265    /// Blocking: this reads the journal files. Async callers use
3266    /// [`Self::read_durable_terminal_history`].
3267    pub fn durable_terminal_history(&self) -> subc_control::TerminalHistory {
3268        durable_terminal_history_of(&self.inner.terminal_ring, &self.inner.module_id)
3269    }
3270
3271    /// [`Self::durable_terminal_history`] on a blocking thread, so the journal
3272    /// read (up to every retained generation) never occupies a runtime worker.
3273    /// Fails only if the blocking task could not finish (runtime shutdown or a
3274    /// panic in the read).
3275    pub(crate) async fn read_durable_terminal_history(
3276        &self,
3277    ) -> Result<subc_control::TerminalHistory, tokio::task::JoinError> {
3278        let terminal_ring = Arc::clone(&self.inner.terminal_ring);
3279        let module_id = self.inner.module_id.clone();
3280        tokio::task::spawn_blocking(move || durable_terminal_history_of(&terminal_ring, &module_id))
3281            .await
3282    }
3283
3284    pub fn status(&self) -> Result<ModuleStatus, SuperviseError> {
3285        self.status_with_snapshot_lock(&self.inner.snapshot, None)
3286            .map(|(status, _)| status)
3287    }
3288
3289    pub(crate) fn record_deliberate_severance(
3290        &self,
3291        identity: ProcessIdentity,
3292    ) -> Result<bool, SuperviseError> {
3293        let mut snapshot = lock_snapshot(&self.inner.snapshot)?;
3294        if snapshot.pid != Some(identity.pid)
3295            || snapshot.process_start_time != Some(identity.start_time)
3296        {
3297            return Ok(false);
3298        }
3299        snapshot.deliberate_severance = Some(identity);
3300        Ok(true)
3301    }
3302
3303    /// Read status for a channel-0 renderer and report a contended snapshot lock.
3304    ///
3305    /// Internal supervision callers use [`Self::status`] so writer-side machinery
3306    /// does not produce reader-observability logs.
3307    pub(crate) fn status_for_control(
3308        &self,
3309        caller: &'static str,
3310    ) -> Result<ModuleStatus, SuperviseError> {
3311        self.status_with_snapshot_lock(&self.inner.snapshot, Some(caller))
3312            .map(|(status, _)| status)
3313    }
3314
3315    fn status_with_snapshot_lock(
3316        &self,
3317        snapshot: &SharedSnapshot,
3318        caller: Option<&'static str>,
3319    ) -> Result<(ModuleStatus, Option<SpawnedFileIdentity>), SuperviseError> {
3320        let mut guard = match caller {
3321            Some(caller) => lock_snapshot_for_control(snapshot, &self.inner.module_id, caller)?,
3322            None => lock_snapshot(snapshot)?,
3323        };
3324        // Read the budget through the pruning path so a reader sees the same
3325        // in-window count the restart decision would use, not a stale total.
3326        let restart_count =
3327            guard.crash_restarts_in_window(self.inner.restart_policy.window, Instant::now());
3328        let snapshot = guard.clone();
3329        drop(guard);
3330        let drain_timeout = *self.inner.effective_drain_timeout.lock().map_err(|_| {
3331            SuperviseError::StatePoisoned {
3332                module_id: Some(self.inner.module_id.clone()),
3333            }
3334        })?;
3335        let registration_active = self
3336            .inner
3337            .registry
3338            .get_module(&self.inner.module_id)
3339            .map_err(SuperviseError::Registry)?
3340            .is_some();
3341        let protocol = snapshot
3342            .spawned_protocol
3343            .unwrap_or(self.declared_protocol()?);
3344        let running_process =
3345            snapshot.enabled && snapshot.state == ModuleState::Running && snapshot.process_alive;
3346        // Registration is the difference between the two protocols and the only
3347        // one: a subc module that has not registered cannot serve a request even
3348        // though its process is up, and a `none` module never registers at all,
3349        // so requiring it there would pin `live` to false for the whole life of
3350        // a perfectly healthy process.
3351        let live = match protocol {
3352            ModuleProtocol::Subc => running_process && registration_active,
3353            ModuleProtocol::None => running_process,
3354        };
3355
3356        Ok((
3357            ModuleStatus {
3358                module_id: self.inner.module_id.clone(),
3359                state: snapshot.state,
3360                enabled: snapshot.enabled,
3361                process_alive: snapshot.process_alive,
3362                registration_active,
3363                protocol,
3364                live,
3365                restart_count,
3366                lifetime_restarts: snapshot.lifetime_restarts,
3367                spawn_generation: snapshot.spawn_generation,
3368                max_restarts: self.inner.restart_policy.max_restarts,
3369                restart_window: self.inner.restart_policy.window,
3370                drain_timeout,
3371                restart_backoff: self.inner.restart_policy.backoff,
3372                restart_max_backoff: self.inner.restart_policy.max_backoff,
3373                pid: snapshot.reported_pid(),
3374                spawned_at_ms: snapshot.spawned_at_ms,
3375                spawned_from: snapshot.spawned_from,
3376                process_start_time: snapshot.process_start_time,
3377                last_exit: snapshot.last_exit,
3378                health: snapshot.health,
3379            },
3380            snapshot.spawned_file_identity,
3381        ))
3382    }
3383
3384    #[cfg(test)]
3385    pub(crate) fn hold_snapshot_for_test(
3386        &self,
3387        acquired: std::sync::mpsc::Sender<()>,
3388        hold: Duration,
3389    ) -> std::thread::JoinHandle<()> {
3390        let snapshot = Arc::clone(&self.inner.snapshot);
3391        std::thread::spawn(move || {
3392            let _guard = snapshot.lock().expect("test snapshot lock is not poisoned");
3393            acquired
3394                .send(())
3395                .expect("test receiver waits for snapshot lock");
3396            std::thread::sleep(hold);
3397        })
3398    }
3399
3400    /// The status and the running-image check for `supervisor.provenance`,
3401    /// taken from one status read. The exec acknowledgement can land between
3402    /// two separate reads, and the reply would then pair "no pid yet" with an
3403    /// image observed after the module started, which describes no single
3404    /// moment.
3405    pub(crate) async fn status_and_running_image_agreement(
3406        &self,
3407    ) -> Result<(ModuleStatus, subc_control::RunningImageAgreement), SuperviseError> {
3408        let (status, identity) = self.status_with_snapshot_lock(&self.inner.snapshot, None)?;
3409        let image = self
3410            .inner
3411            .provenance_probe
3412            .observe(
3413                status.pid,
3414                status.spawned_from.as_deref(),
3415                identity,
3416                status.process_start_time,
3417            )
3418            .await;
3419        Ok((status, image))
3420    }
3421
3422    pub(crate) async fn running_image_agreement(&self) -> subc_control::RunningImageAgreement {
3423        let snapshot = match lock_snapshot(&self.inner.snapshot) {
3424            Ok(snapshot) => snapshot.clone(),
3425            Err(_) => {
3426                return subc_control::RunningImageAgreement::Unavailable {
3427                    reason: subc_control::RunningImageUnavailableReason::NotRunning,
3428                };
3429            }
3430        };
3431        self.inner
3432            .provenance_probe
3433            .observe(
3434                snapshot.reported_pid(),
3435                snapshot.spawned_from.as_deref(),
3436                snapshot.spawned_file_identity,
3437                snapshot.process_start_time,
3438            )
3439            .await
3440    }
3441
3442    /// Memory and CPU time of the module's current process, read now. Only the
3443    /// process the supervisor spawned is read, not processes it has started.
3444    pub(crate) fn child_resource_usage(&self) -> subc_control::ChildResourceUsage {
3445        let (pid, start_time) = match lock_snapshot(&self.inner.snapshot) {
3446            Ok(snapshot) => (snapshot.reported_pid(), snapshot.process_start_time),
3447            Err(_) => {
3448                return subc_control::ChildResourceUsage::Unavailable {
3449                    reason: subc_control::ChildResourceUnavailableReason::Unreadable,
3450                }
3451            }
3452        };
3453        crate::child_resources::read(pid, start_time)
3454    }
3455
3456    pub(crate) fn will_recover_after_connection_loss(&self) -> Result<bool, SuperviseError> {
3457        let mut snapshot = lock_snapshot(&self.inner.snapshot)?;
3458        Ok(match snapshot.state {
3459            ModuleState::Restarting => true,
3460            ModuleState::Failed | ModuleState::Disabled => false,
3461            _ => daemon_will_restart(&mut snapshot, &self.inner.restart_policy, Instant::now()),
3462        })
3463    }
3464
3465    #[cfg(test)]
3466    pub(crate) fn is_warming(&self) -> Result<bool, SuperviseError> {
3467        self.is_warming_with_snapshot_lock(None)
3468    }
3469
3470    pub(crate) fn is_warming_for_control(
3471        &self,
3472        caller: &'static str,
3473    ) -> Result<bool, SuperviseError> {
3474        self.is_warming_with_snapshot_lock(Some(caller))
3475    }
3476
3477    fn is_warming_with_snapshot_lock(
3478        &self,
3479        caller: Option<&'static str>,
3480    ) -> Result<bool, SuperviseError> {
3481        let snapshot = match caller {
3482            Some(caller) => {
3483                lock_snapshot_for_control(&self.inner.snapshot, &self.inner.module_id, caller)?
3484            }
3485            None => lock_snapshot(&self.inner.snapshot)?,
3486        }
3487        .clone();
3488        Ok(matches!(
3489            snapshot.state,
3490            ModuleState::Starting | ModuleState::Running | ModuleState::Restarting
3491        ))
3492    }
3493
3494    /// Drain the module and stop monitoring it.
3495    pub async fn drain(&self) -> Result<(), SuperviseError> {
3496        self.stop().await
3497    }
3498
3499    pub(crate) async fn retire(&self) -> Result<(), SuperviseError> {
3500        match self.state()? {
3501            ModuleState::Stopped | ModuleState::Failed => return Ok(()),
3502            ModuleState::Starting
3503            | ModuleState::Running
3504            | ModuleState::Unresponsive
3505            | ModuleState::Restarting
3506            | ModuleState::Draining
3507            | ModuleState::Disabled => {}
3508        }
3509
3510        let (reply_tx, reply_rx) = oneshot::channel();
3511        self.inner
3512            .commands
3513            .send(SupervisorCommand::Retire { reply: reply_tx })
3514            .await
3515            .map_err(|_| SuperviseError::CommandClosed {
3516                module_id: self.inner.module_id.clone(),
3517            })?;
3518        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3519            module_id: self.inner.module_id.clone(),
3520        })?
3521    }
3522
3523    pub async fn stop(&self) -> Result<(), SuperviseError> {
3524        match self.state()? {
3525            ModuleState::Stopped | ModuleState::Failed => return Ok(()),
3526            ModuleState::Starting
3527            | ModuleState::Running
3528            | ModuleState::Unresponsive
3529            | ModuleState::Restarting
3530            | ModuleState::Draining
3531            | ModuleState::Disabled => {}
3532        }
3533
3534        let (reply_tx, reply_rx) = oneshot::channel();
3535        self.inner
3536            .commands
3537            .send(SupervisorCommand::Drain { reply: reply_tx })
3538            .await
3539            .map_err(|_| SuperviseError::CommandClosed {
3540                module_id: self.inner.module_id.clone(),
3541            })?;
3542        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3543            module_id: self.inner.module_id.clone(),
3544        })?
3545    }
3546
3547    pub async fn restart(&self, drain_timeout_ms: Option<u64>) -> Result<(), SuperviseError> {
3548        let received_at_generation = lock_snapshot(&self.inner.snapshot)?.spawn_generation;
3549        let (reply_tx, reply_rx) = oneshot::channel();
3550        self.inner
3551            .commands
3552            .send(SupervisorCommand::Restart {
3553                drain_timeout_ms,
3554                received_at_generation,
3555                queued_at: Instant::now(),
3556                reply: reply_tx,
3557            })
3558            .await
3559            .map_err(|_| SuperviseError::CommandClosed {
3560                module_id: self.inner.module_id.clone(),
3561            })?;
3562        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3563            module_id: self.inner.module_id.clone(),
3564        })?
3565    }
3566
3567    /// Blue/green restart: see [`SupervisorCommand::Swap`] and the
3568    /// `supervisor_swap` module. Returns once the swap has cut over (the old
3569    /// process then drains in the background of the supervise loop) or has
3570    /// failed, leaving the old process serving.
3571    pub async fn swap(&self, ready_timeout: Option<Duration>) -> Result<(), SuperviseError> {
3572        let (reply_tx, reply_rx) = oneshot::channel();
3573        self.inner
3574            .commands
3575            .send(SupervisorCommand::Swap {
3576                ready_timeout,
3577                reply: reply_tx,
3578            })
3579            .await
3580            .map_err(|_| SuperviseError::CommandClosed {
3581                module_id: self.inner.module_id.clone(),
3582            })?;
3583        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3584            module_id: self.inner.module_id.clone(),
3585        })?
3586    }
3587
3588    pub async fn reload(&self) -> Result<(), SuperviseError> {
3589        let (reply_tx, reply_rx) = oneshot::channel();
3590        self.inner
3591            .commands
3592            .send(SupervisorCommand::Reload { reply: reply_tx })
3593            .await
3594            .map_err(|_| SuperviseError::CommandClosed {
3595                module_id: self.inner.module_id.clone(),
3596            })?;
3597        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3598            module_id: self.inner.module_id.clone(),
3599        })?
3600    }
3601
3602    pub async fn set_enabled(&self, enabled: bool) -> Result<bool, SuperviseError> {
3603        let (reply_tx, reply_rx) = oneshot::channel();
3604        self.inner
3605            .commands
3606            .send(SupervisorCommand::SetEnabled {
3607                enabled,
3608                reply: reply_tx,
3609            })
3610            .await
3611            .map_err(|_| SuperviseError::CommandClosed {
3612                module_id: self.inner.module_id.clone(),
3613            })?;
3614        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3615            module_id: self.inner.module_id.clone(),
3616        })?
3617    }
3618
3619    /// The current process's protocol, or the configured protocol when down.
3620    /// A rescan stores the next launch spec without changing how an existing
3621    /// process registers, serves routes, is probed, or exits.
3622    pub(crate) fn declared_protocol(&self) -> Result<ModuleProtocol, SuperviseError> {
3623        let configured = self
3624            .inner
3625            .configuration
3626            .lock()
3627            .map_err(|_| SuperviseError::StatePoisoned {
3628                module_id: Some(self.inner.module_id.clone()),
3629            })?
3630            .spec
3631            .protocol;
3632        let state = lock_snapshot(&self.inner.snapshot)?;
3633        Ok(state.spawned_protocol.unwrap_or(configured))
3634    }
3635
3636    pub(crate) fn configuration(&self) -> Result<(ModuleSpec, HealthConfig), SuperviseError> {
3637        let configuration =
3638            self.inner
3639                .configuration
3640                .lock()
3641                .map_err(|_| SuperviseError::StatePoisoned {
3642                    module_id: Some(self.inner.module_id.clone()),
3643                })?;
3644        Ok((configuration.spec.clone(), configuration.health.clone()))
3645    }
3646
3647    /// Replace this module's launch spec, keeping its health and drain policy,
3648    /// the way a rescan does for a changed config entry. The running process is
3649    /// untouched; the next spawn (a restart, or a swap's candidate) uses it.
3650    #[cfg(any(test, feature = "test-support"))]
3651    pub async fn update_spec_for_test(&self, spec: ModuleSpec) -> Result<(), SuperviseError> {
3652        let (_, health) = self.configuration()?;
3653        let drain_timeout_ms = u64::try_from(
3654            self.inner
3655                .effective_drain_timeout
3656                .lock()
3657                .unwrap_or_else(|poisoned| poisoned.into_inner())
3658                .as_millis(),
3659        )
3660        .ok();
3661        self.update_configuration(spec, health, drain_timeout_ms)
3662            .await
3663    }
3664
3665    pub(crate) async fn update_configuration(
3666        &self,
3667        spec: ModuleSpec,
3668        health: HealthConfig,
3669        drain_timeout_ms: Option<u64>,
3670    ) -> Result<(), SuperviseError> {
3671        if spec.module_id != self.inner.module_id {
3672            return Err(SuperviseError::InvalidSpec {
3673                reason: "a supervised module's module_id cannot be changed".to_string(),
3674            });
3675        }
3676        validate_spec(&spec)?;
3677        let (reply_tx, reply_rx) = oneshot::channel();
3678        self.inner
3679            .commands
3680            .send(SupervisorCommand::UpdateConfiguration {
3681                spec: spec.clone(),
3682                health: health.clone(),
3683                drain_timeout_ms,
3684                reply: reply_tx,
3685            })
3686            .await
3687            .map_err(|_| SuperviseError::CommandClosed {
3688                module_id: self.inner.module_id.clone(),
3689            })?;
3690        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3691            module_id: self.inner.module_id.clone(),
3692        })?;
3693        let mut configuration =
3694            self.inner
3695                .configuration
3696                .lock()
3697                .map_err(|_| SuperviseError::StatePoisoned {
3698                    module_id: Some(self.inner.module_id.clone()),
3699                })?;
3700        configuration.spec = spec;
3701        configuration.health = health;
3702        Ok(())
3703    }
3704}
3705
3706impl Drop for SupervisedModuleInner {
3707    fn drop(&mut self) {
3708        let Ok(mut monitor) = self.monitor.lock() else {
3709            return;
3710        };
3711        if let Some(monitor) = monitor.as_ref().filter(|monitor| !monitor.is_finished()) {
3712            let _ = update_snapshot(&self.snapshot, Some(&self.module_id), |state| {
3713                state.state = ModuleState::Stopped;
3714                clear_current_process_facts(state);
3715            });
3716            monitor.abort();
3717        }
3718        let _ = monitor.take();
3719    }
3720}
3721
3722#[derive(Debug)]
3723enum SupervisorCommand {
3724    Drain {
3725        reply: oneshot::Sender<Result<(), SuperviseError>>,
3726    },
3727    Retire {
3728        reply: oneshot::Sender<Result<(), SuperviseError>>,
3729    },
3730    Restart {
3731        /// Operator override for this one restart's drain budget, in ms. `None`
3732        /// uses the module's configured/default budget; `Some(0)` cuts
3733        /// immediately (wedge bounce: a stuck request never settles, so
3734        /// waiting only delays recovery).
3735        drain_timeout_ms: Option<u64>,
3736        /// The module's `spawn_generation` when the request was received, before
3737        /// it waited in the command queue. A queued restart whose module has
3738        /// since spawned a newer process is already satisfied (see the handler).
3739        received_at_generation: u64,
3740        /// When the request entered the command queue, so the handler can log
3741        /// how long it waited behind the loop's other work.
3742        queued_at: Instant,
3743        reply: oneshot::Sender<Result<(), SuperviseError>>,
3744    },
3745    Reload {
3746        reply: oneshot::Sender<Result<(), SuperviseError>>,
3747    },
3748    SetEnabled {
3749        enabled: bool,
3750        reply: oneshot::Sender<Result<bool, SuperviseError>>,
3751    },
3752    UpdateConfiguration {
3753        spec: ModuleSpec,
3754        health: HealthConfig,
3755        /// Per-module drain override from the new config; `None` re-resolves to
3756        /// the supervisor-wide default.
3757        drain_timeout_ms: Option<u64>,
3758        reply: oneshot::Sender<()>,
3759    },
3760    Swap {
3761        /// How long the candidate may take to register and declare itself
3762        /// ready. `None` uses [`DEFAULT_SWAP_READY_TIMEOUT`].
3763        ready_timeout: Option<Duration>,
3764        /// Answered at cutover or failure; the incumbent's drain follows.
3765        reply: oneshot::Sender<Result<(), SuperviseError>>,
3766    },
3767}
3768
3769#[derive(Debug)]
3770pub enum SuperviseError {
3771    InvalidSpec {
3772        reason: String,
3773    },
3774    Spawn {
3775        program: PathBuf,
3776        source: io::Error,
3777        cgroup_path: Option<PathBuf>,
3778    },
3779    Cgroup {
3780        module_id: String,
3781        source: io::Error,
3782    },
3783    /// CSPRNG failure generating a reserved module's launch nonce. Fail loud rather
3784    /// than spawn a reserved module without its identity binding.
3785    LaunchNonce {
3786        reason: String,
3787    },
3788    Wait {
3789        module_id: String,
3790        source: io::Error,
3791    },
3792    Kill {
3793        module_id: String,
3794        source: io::Error,
3795    },
3796    Forwarding(ForwardingError),
3797    Registry(RegistryError),
3798    ReloadUnavailable {
3799        module_id: String,
3800        reason: String,
3801    },
3802    /// An operator restart/reload was requested for a module that is currently
3803    /// disabled. Restart/reload cycle a *running* module; a disabled module must
3804    /// be explicitly re-enabled (set_enabled(true)) rather than silently started
3805    /// by a restart, so these commands are rejected instead of re-enabling it.
3806    Disabled {
3807        module_id: String,
3808    },
3809    ReloadFailed {
3810        module_id: String,
3811        reason: String,
3812    },
3813    RegistrationStillActive {
3814        module_id: String,
3815        waited: Duration,
3816    },
3817    StatePoisoned {
3818        module_id: Option<String>,
3819    },
3820    CommandClosed {
3821        module_id: String,
3822    },
3823    /// A restart or reload arrived while a swap's candidate was warming. The
3824    /// swap owns the module until it cuts over or fails; a stop or disable
3825    /// would have aborted it instead.
3826    SwapInProgress {
3827        module_id: String,
3828    },
3829    /// A swap was refused before anything was spawned.
3830    SwapRefused {
3831        module_id: String,
3832        reason: SwapRefusal,
3833    },
3834    /// A swap spawned a candidate and gave up on it. The candidate has been
3835    /// killed and its slot freed; the incumbent was left serving and was never
3836    /// drained, except in the one `CutoverLost` case described on that arm.
3837    SwapFailed {
3838        module_id: String,
3839        arm: SwapFailureArm,
3840        detail: String,
3841        /// How the candidate exited, when it exited on its own before the
3842        /// supervisor gave up on it.
3843        candidate_exit: Option<ExitReport>,
3844    },
3845}
3846
3847/// Why a swap was refused before a candidate was spawned.
3848#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3849pub enum SwapRefusal {
3850    /// The module's config does not declare `overlap: "safe"`.
3851    OverlapExclusive,
3852    /// The module is not registered, so there is no incumbent to keep serving
3853    /// and nothing a swap would improve on; a plain restart is the tool.
3854    NotRegistered,
3855    /// The module does not speak the subc wire, so a candidate could never
3856    /// register or declare itself ready.
3857    ProtocolNone,
3858    /// The supervisor lacks the forwarding table (to cut routes over) or the
3859    /// shared handle (to admit the candidate's HELLO) that a swap needs.
3860    NotConfigured,
3861    /// A swap is already open for this module.
3862    AlreadySwapping,
3863}
3864
3865impl SwapRefusal {
3866    pub fn as_str(self) -> &'static str {
3867        match self {
3868            Self::OverlapExclusive => "overlap_exclusive",
3869            Self::NotRegistered => "not_registered",
3870            Self::ProtocolNone => "protocol_none",
3871            Self::NotConfigured => "not_configured",
3872            Self::AlreadySwapping => "already_swapping",
3873        }
3874    }
3875}
3876
3877/// Which failure arm ended a swap. Every arm but one leaves the incumbent
3878/// serving and undrained; see `CutoverLost`.
3879#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3880pub enum SwapFailureArm {
3881    /// The candidate process could not be started.
3882    SpawnFailed,
3883    /// The candidate did not register within the readiness budget.
3884    NeverRegistered,
3885    /// The candidate registered but did not declare itself ready in time.
3886    NeverReady,
3887    /// The candidate exited before cutover.
3888    CandidateExited,
3889    /// The candidate declared itself ready but failed its health probe.
3890    CandidateUnhealthy,
3891    /// An operator stop, disable or retire arrived while the candidate warmed.
3892    /// The candidate was killed and the operator's command then carried out on
3893    /// the incumbent.
3894    Interrupted,
3895    /// The candidate's connection closed at the moment of cutover. If it
3896    /// closed before forwarding moved, the incumbent is untouched. If it closed
3897    /// between the forwarding and registry halves of cutover, forwarding can no
3898    /// longer route to the incumbent, so the module is restarted plainly.
3899    CutoverLost,
3900}
3901
3902impl SwapFailureArm {
3903    pub fn as_str(self) -> &'static str {
3904        match self {
3905            Self::SpawnFailed => "spawn_failed",
3906            Self::NeverRegistered => "never_registered",
3907            Self::NeverReady => "never_ready",
3908            Self::CandidateExited => "candidate_exited",
3909            Self::CandidateUnhealthy => "candidate_unhealthy",
3910            Self::Interrupted => "interrupted",
3911            Self::CutoverLost => "cutover_lost",
3912        }
3913    }
3914}
3915
3916impl fmt::Display for SuperviseError {
3917    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3918        match self {
3919            Self::InvalidSpec { reason } => write!(f, "invalid module spec: {reason}"),
3920            Self::Spawn {
3921                program,
3922                source,
3923                cgroup_path: Some(cgroup_path),
3924            } => write!(
3925                f,
3926                "failed to place module in cgroup '{}' while spawning '{}': {source}",
3927                cgroup_path.display(),
3928                program.display()
3929            ),
3930            Self::Spawn {
3931                program,
3932                source,
3933                cgroup_path: None,
3934            } => write!(
3935                f,
3936                "failed to spawn module '{}': {source}",
3937                program.display()
3938            ),
3939            Self::Cgroup { module_id, source } => {
3940                write!(
3941                    f,
3942                    "failed to prepare cgroup for module '{module_id}': {source}"
3943                )
3944            }
3945            Self::LaunchNonce { reason } => {
3946                write!(
3947                    f,
3948                    "failed to generate reserved-module launch nonce: {reason}"
3949                )
3950            }
3951            Self::Wait { module_id, source } => {
3952                write!(f, "failed to wait for module '{module_id}': {source}")
3953            }
3954            Self::Kill { module_id, source } => {
3955                write!(f, "failed to kill module '{module_id}': {source}")
3956            }
3957            Self::Forwarding(err) => write!(f, "forwarding error: {err}"),
3958            Self::Registry(err) => write!(f, "registry error: {err}"),
3959            Self::ReloadUnavailable { module_id, reason } => {
3960                write!(f, "reload unavailable for module '{module_id}': {reason}")
3961            }
3962            Self::Disabled { module_id } => {
3963                write!(
3964                    f,
3965                    "module '{module_id}' is disabled; enable it before restart or reload"
3966                )
3967            }
3968            Self::ReloadFailed { module_id, reason } => {
3969                write!(f, "reload failed for module '{module_id}': {reason}")
3970            }
3971            Self::RegistrationStillActive { module_id, waited } => write!(
3972                f,
3973                "module '{module_id}' registration remained active after waiting {waited:?}"
3974            ),
3975            Self::StatePoisoned { module_id } => match module_id {
3976                Some(module_id) => {
3977                    write!(f, "supervisor state for module '{module_id}' was poisoned")
3978                }
3979                None => write!(f, "supervisor state was poisoned"),
3980            },
3981            Self::CommandClosed { module_id } => {
3982                write!(
3983                    f,
3984                    "supervisor command channel for module '{module_id}' is closed"
3985                )
3986            }
3987            Self::SwapInProgress { module_id } => write!(
3988                f,
3989                "module '{module_id}' is being swapped; retry once the swap has cut over or failed, or stop the module to abort the swap"
3990            ),
3991            Self::SwapRefused { module_id, reason } => match reason {
3992                SwapRefusal::OverlapExclusive => write!(
3993                    f,
3994                    "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"
3995                ),
3996                SwapRefusal::NotRegistered => write!(
3997                    f,
3998                    "module '{module_id}' is not registered, so there is no serving process to keep while a replacement warms; use a plain restart"
3999                ),
4000                SwapRefusal::ProtocolNone => write!(
4001                    f,
4002                    "module '{module_id}' is protocol: \"none\" and never registers, so a swap could never see its replacement become ready; use a plain restart"
4003                ),
4004                SwapRefusal::NotConfigured => write!(
4005                    f,
4006                    "module '{module_id}' cannot be swapped: the supervisor was built without the forwarding table or shared handle a swap needs"
4007                ),
4008                SwapRefusal::AlreadySwapping => {
4009                    write!(f, "module '{module_id}' is already being swapped")
4010                }
4011            },
4012            Self::SwapFailed {
4013                module_id,
4014                arm,
4015                detail,
4016                ..
4017            } => write!(
4018                f,
4019                "swap of module '{module_id}' failed ({}): {detail}; the running process was left serving",
4020                arm.as_str()
4021            ),
4022        }
4023    }
4024}
4025
4026impl Error for SuperviseError {
4027    fn source(&self) -> Option<&(dyn Error + 'static)> {
4028        match self {
4029            Self::Spawn { source, .. }
4030            | Self::Cgroup { source, .. }
4031            | Self::Wait { source, .. }
4032            | Self::Kill { source, .. } => Some(source),
4033            Self::Forwarding(err) => Some(err),
4034            Self::Registry(err) => Some(err),
4035            Self::LaunchNonce { .. }
4036            | Self::InvalidSpec { .. }
4037            | Self::ReloadUnavailable { .. }
4038            | Self::Disabled { .. }
4039            | Self::ReloadFailed { .. }
4040            | Self::RegistrationStillActive { .. }
4041            | Self::StatePoisoned { .. }
4042            | Self::CommandClosed { .. }
4043            | Self::SwapInProgress { .. }
4044            | Self::SwapRefused { .. }
4045            | Self::SwapFailed { .. } => None,
4046        }
4047    }
4048}
4049
4050pub(crate) fn validate_spec(spec: &ModuleSpec) -> Result<(), SuperviseError> {
4051    if spec.module_id.trim().is_empty() {
4052        return Err(SuperviseError::InvalidSpec {
4053            reason: "module_id must not be empty".to_string(),
4054        });
4055    }
4056
4057    Ok(())
4058}
4059
4060#[derive(Debug, Default)]
4061struct HealthProbeRuntime {
4062    configured_health: Option<HealthConfig>,
4063    registered_connection: Option<crate::ConnectionId>,
4064    advertised: bool,
4065    next_probe_at: Option<Instant>,
4066    probe_index: u64,
4067}
4068
4069fn running_protocol(spec: &ModuleSpec, snapshot: &SharedSnapshot) -> ModuleProtocol {
4070    lock_snapshot(snapshot)
4071        .ok()
4072        .and_then(|state| state.spawned_protocol)
4073        .unwrap_or(spec.protocol)
4074}
4075
4076impl HealthProbeRuntime {
4077    fn refresh_registration(
4078        &mut self,
4079        spec: &ModuleSpec,
4080        runtime: &SupervisorRuntimeConfig,
4081        registry: &Registry,
4082        snapshot: &SharedSnapshot,
4083    ) {
4084        if self.configured_health.as_ref() != Some(&runtime.health) {
4085            self.configured_health = Some(runtime.health.clone());
4086            self.next_probe_at = None;
4087            self.registered_connection = None;
4088            self.probe_index = 0;
4089        }
4090        // A non-wire process never registers. Only an explicitly configured
4091        // HTTP endpoint can arm its health probe; an absent HELLO is not a
4092        // health failure for that kind of process.
4093        if running_protocol(spec, snapshot) == ModuleProtocol::None {
4094            self.registered_connection = None;
4095            self.advertised = runtime.health.http.is_some();
4096            if !self.advertised {
4097                self.next_probe_at = None;
4098                let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4099                    let unknown = ModuleHealthStatus::default();
4100                    if state.health != unknown {
4101                        state.health = unknown;
4102                    }
4103                });
4104            } else if self.next_probe_at.is_none() {
4105                self.next_probe_at = Some(
4106                    Instant::now()
4107                        + jittered_health_delay(&spec.module_id, 0, runtime.health.cadence),
4108                );
4109            }
4110            return;
4111        }
4112
4113        let registration = match registry.get_module(&spec.module_id) {
4114            Ok(registration) => registration,
4115            Err(err) => {
4116                warn!(module_id = %spec.module_id, error = %err, "health prober could not read registry");
4117                self.advertised = false;
4118                self.next_probe_at = None;
4119                return;
4120            }
4121        };
4122
4123        let Some(registration) = registration else {
4124            self.registered_connection = None;
4125            self.advertised = false;
4126            self.next_probe_at = None;
4127            return;
4128        };
4129
4130        let advertised = registration
4131            .control_ops
4132            .iter()
4133            .any(|op| op == MODULE_CONTROL_OP_HEALTH_CHECK);
4134        if !advertised {
4135            self.registered_connection = Some(registration.connection_id);
4136            self.advertised = false;
4137            self.next_probe_at = None;
4138            let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4139                if state.health.status != SupervisorHealthStatus::Unknown
4140                    || state.health.consecutive_failures != 0
4141                    || state.health.last_probe_ms.is_some()
4142                    || state.health.detail.is_some()
4143                    || state.health.metrics.is_some()
4144                {
4145                    state.health.status = SupervisorHealthStatus::Unknown;
4146                    state.health.consecutive_failures = 0;
4147                    state.health.last_probe_ms = None;
4148                    state.health.detail = None;
4149                    state.health.metrics = None;
4150                }
4151            });
4152            return;
4153        }
4154
4155        let reregistered = self.registered_connection != Some(registration.connection_id);
4156        self.registered_connection = Some(registration.connection_id);
4157        self.advertised = true;
4158        if reregistered || self.next_probe_at.is_none() {
4159            self.probe_index = 0;
4160            self.next_probe_at = Some(
4161                Instant::now() + jittered_health_delay(&spec.module_id, 0, runtime.health.cadence),
4162            );
4163            let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4164                state.health.status = SupervisorHealthStatus::Unknown;
4165                state.health.consecutive_failures = 0;
4166                state.health.detail = None;
4167                state.health.metrics = None;
4168            });
4169        }
4170    }
4171
4172    fn wake_after(&self) -> Option<Duration> {
4173        if !self.advertised {
4174            return None;
4175        }
4176        self.next_probe_at
4177            .map(|next| next.saturating_duration_since(Instant::now()))
4178    }
4179
4180    fn due(&self) -> bool {
4181        self.advertised
4182            && self
4183                .next_probe_at
4184                .is_some_and(|next| Instant::now() >= next)
4185    }
4186
4187    fn schedule_next(&mut self, spec: &ModuleSpec, cadence: Duration) {
4188        self.probe_index = self.probe_index.wrapping_add(1);
4189        self.next_probe_at = Some(
4190            Instant::now() + jittered_health_delay(&spec.module_id, self.probe_index, cadence),
4191        );
4192    }
4193}
4194
4195/// What a failed health probe actually OBSERVED, kept apart from how it reads.
4196///
4197/// This was a struct with a single `message: String`, and every one of the
4198/// fifteen construction sites collapsed into it. Each site knows exactly what it
4199/// saw -- the lane is gone, the module did not answer in time, the module
4200/// answered with the wrong thing -- and `handle_health_probe_failure` then
4201/// treated all of them identically: increment a counter, compare to a threshold,
4202/// restart the module. THE DISTINCTION EXISTED AT EVERY CALL SITE AND WAS
4203/// DESTROYED BEFORE THE DECISION THAT NEEDED IT.
4204///
4205/// The distinction that matters is not severity, it is EVIDENTIAL WEIGHT:
4206///
4207/// * `LaneDead` is PROOF. The module's control connection is gone; nothing will
4208///   answer on it again.
4209/// * `NoAnswer` is ABSENCE OF EVIDENCE. It is consistent with a wedged module
4210///   AND with a perfectly healthy one that lost a CPU race -- which is what
4211///   happens under machine load, and is how this supervisor killed a healthy
4212///   module three times in one day.
4213/// * `BadAnswer` proves the module is ALIVE. It replied; the reply was wrong.
4214///   Restarting on it is defensible, but it is not the silence case and should
4215///   never be counted as one.
4216/// * `Misconfigured` is a daemon-side fault. The module has not been asked
4217///   anything, so it cannot be evidence about the module at all.
4218///
4219/// The asymmetry is the whole point: under saturation the WEAKEST signal is the
4220/// one that fires most often, and while every variant collapsed into one string
4221/// it carried the same weight as the strongest.
4222///
4223/// LIVE BEHAVIOUR TODAY, stated here because this doc block describes the
4224/// DESIGN and a reader stopping at it gets the build backwards: the restart
4225/// decision does NOT yet consult this classification -- consecutive `NoAnswer`
4226/// probes still increment the failure streak and drive escalation at the
4227/// threshold (see `is_proof_of_death` below for why that is deliberate and
4228/// what gates the change). Absence of evidence restarts modules today.
4229#[derive(Debug)]
4230enum HealthProbeEvidence {
4231    /// The module's control lane is gone. Proof of death.
4232    LaneDead,
4233    /// No reply within the deadline. Proves nothing about the module's state.
4234    NoAnswer,
4235    /// The module replied, but not with a usable health report. Proves it is alive.
4236    BadAnswer,
4237    /// The daemon could not ask. Says nothing about the module.
4238    Misconfigured,
4239}
4240
4241#[derive(Debug)]
4242struct HealthProbeError {
4243    evidence: HealthProbeEvidence,
4244    message: String,
4245}
4246
4247impl HealthProbeError {
4248    fn lane_dead(message: impl Into<String>) -> Self {
4249        Self::with(HealthProbeEvidence::LaneDead, message)
4250    }
4251
4252    fn no_answer(message: impl Into<String>) -> Self {
4253        Self::with(HealthProbeEvidence::NoAnswer, message)
4254    }
4255
4256    fn bad_answer(message: impl Into<String>) -> Self {
4257        Self::with(HealthProbeEvidence::BadAnswer, message)
4258    }
4259
4260    fn misconfigured(message: impl Into<String>) -> Self {
4261        Self::with(HealthProbeEvidence::Misconfigured, message)
4262    }
4263
4264    fn with(evidence: HealthProbeEvidence, message: impl Into<String>) -> Self {
4265        Self {
4266            evidence,
4267            message: message.into(),
4268        }
4269    }
4270
4271    /// Whether this observation is proof the module cannot serve.
4272    ///
4273    /// Only `LaneDead` qualifies. `NoAnswer` is deliberately excluded: it is the
4274    /// variant that fires under CPU starvation, and treating it as proof is the
4275    /// defect this enum exists to make impossible to reintroduce silently.
4276    ///
4277    /// NOT YET CONSULTED BY THE RESTART DECISION, deliberately. Requiring proof
4278    /// to restart also needs a bound for the case it excludes -- a genuinely
4279    /// wedged module, alive but never answering -- and that bound must come from
4280    /// the distribution of real late-answer latencies, which nothing measures
4281    /// yet. Landing the classification first makes the later change a one-line
4282    /// decision against evidence that already exists, rather than two unproven
4283    /// changes at once.
4284    #[allow(dead_code)]
4285    fn is_proof_of_death(&self) -> bool {
4286        matches!(self.evidence, HealthProbeEvidence::LaneDead)
4287    }
4288
4289    /// Short stable label for logs and the health snapshot.
4290    ///
4291    /// An operator reading `ck health` currently cannot tell "the module is gone"
4292    /// from "the module did not answer in five seconds", because both render as
4293    /// prose in the same field. These labels are what make the two
4294    /// distinguishable at a glance, and they are what a later restart-policy
4295    /// change will be argued from.
4296    fn label(&self) -> &'static str {
4297        match self.evidence {
4298            HealthProbeEvidence::LaneDead => "lane-dead",
4299            HealthProbeEvidence::NoAnswer => "no-answer",
4300            HealthProbeEvidence::BadAnswer => "bad-answer",
4301            HealthProbeEvidence::Misconfigured => "daemon-misconfigured",
4302        }
4303    }
4304}
4305
4306impl fmt::Display for HealthProbeError {
4307    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4308        f.write_str(&self.message)
4309    }
4310}
4311
4312async fn run_health_probe_cycle(
4313    spec: &ModuleSpec,
4314    runtime: &SupervisorRuntimeConfig,
4315    registry: &Registry,
4316    process_liveness: &SupervisorProcessLiveness,
4317    snapshot: &SharedSnapshot,
4318    child: &mut Option<SupervisedChild>,
4319) {
4320    let now_ms = unix_ms_now();
4321    let http = (running_protocol(spec, snapshot) == ModuleProtocol::None)
4322        .then_some(runtime.health.http.as_deref())
4323        .flatten();
4324    let result = match http {
4325        Some(url) => probe_http_health(url, runtime.health.deadline).await,
4326        None => probe_module_health(&spec.module_id, runtime, None).await,
4327    };
4328    match result {
4329        Ok(report) => {
4330            handle_health_report(
4331                spec,
4332                runtime,
4333                registry,
4334                process_liveness,
4335                snapshot,
4336                child,
4337                report,
4338                now_ms,
4339            )
4340            .await;
4341        }
4342        Err(err) => {
4343            if http.is_some() {
4344                let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4345                    state.health.status = SupervisorHealthStatus::Failing;
4346                });
4347            }
4348            handle_health_probe_failure(
4349                spec,
4350                runtime,
4351                registry,
4352                process_liveness,
4353                snapshot,
4354                child,
4355                err,
4356                now_ms,
4357            )
4358            .await;
4359        }
4360    }
4361}
4362
4363pub(crate) struct HttpProbeTarget<'a> {
4364    address: std::net::SocketAddr,
4365    localhost: bool,
4366    authority: &'a str,
4367    path: String,
4368}
4369
4370/// Resolve only literal loopback endpoints, without DNS, redirects, proxies,
4371/// or TLS. A URL cannot turn a local health check into an outbound connection.
4372pub(crate) fn parse_http_probe_url(url: &str) -> Result<HttpProbeTarget<'_>, String> {
4373    if url.bytes().any(|b| b <= b' ' || b == 127) || url.contains('#') {
4374        return Err("must not contain whitespace, controls, or a fragment".into());
4375    }
4376    let rest = url
4377        .strip_prefix("http://")
4378        .ok_or("must use plain http://")?;
4379    let split = rest.find(['/', '?']).unwrap_or(rest.len());
4380    let (authority, suffix) = rest.split_at(split);
4381    let (host, port) = if let Some(rest) = authority.strip_prefix("[::1]") {
4382        ("::1", rest)
4383    } else {
4384        let split = authority.find(':').unwrap_or(authority.len());
4385        authority.split_at(split)
4386    };
4387    let ip: std::net::IpAddr = match host {
4388        "127.0.0.1" | "localhost" => std::net::Ipv4Addr::LOCALHOST.into(),
4389        "::1" => std::net::Ipv6Addr::LOCALHOST.into(),
4390        _ => return Err("host must be 127.0.0.1, [::1], or localhost".into()),
4391    };
4392    let port = if port.is_empty() {
4393        80
4394    } else {
4395        port.strip_prefix(':')
4396            .and_then(|p| p.parse::<u16>().ok())
4397            .filter(|p| *p > 0)
4398            .ok_or("must have a valid nonzero TCP port")?
4399    };
4400    let path = if suffix.is_empty() {
4401        "/".into()
4402    } else if suffix.starts_with('?') {
4403        format!("/{suffix}")
4404    } else {
4405        suffix.into()
4406    };
4407    Ok(HttpProbeTarget {
4408        address: std::net::SocketAddr::new(ip, port),
4409        localhost: host == "localhost",
4410        authority,
4411        path,
4412    })
4413}
4414
4415async fn probe_http_health(
4416    url: &str,
4417    deadline: Duration,
4418) -> Result<HealthReport, HealthProbeError> {
4419    use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader};
4420    let target = parse_http_probe_url(url).map_err(HealthProbeError::misconfigured)?;
4421    // Keep partial diagnostics outside the timed future so cancellation does
4422    // not discard a status line or body bytes already received.
4423    let mut response_status = String::new();
4424    let mut body = Vec::new();
4425    let probe = async {
4426        // Resolve localhost ourselves so a hosts-file override cannot turn
4427        // this into an outbound request, while IPv6-only local servers work.
4428        let connection = match tokio::net::TcpStream::connect(target.address).await {
4429            Err(_) if target.localhost => {
4430                tokio::net::TcpStream::connect((
4431                    std::net::Ipv6Addr::LOCALHOST,
4432                    target.address.port(),
4433                ))
4434                .await
4435            }
4436            result => result,
4437        };
4438        let mut stream = connection.map_err(|error| {
4439            HealthProbeError::no_answer(format!("HTTP connect failed: {error}"))
4440        })?;
4441        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
4442            .map_err(|error| HealthProbeError::no_answer(format!("HTTP write failed: {error}")))?;
4443        let mut reader = BufReader::new(stream);
4444        let mut budget = 16 * 1024;
4445        let status = http_line(&mut reader, &mut budget).await?;
4446        let mut words = status.split_ascii_whitespace();
4447        let version = words.next();
4448        let code = words
4449            .next()
4450            .filter(|word| word.len() == 3)
4451            .and_then(|word| word.parse::<u16>().ok());
4452        if !matches!(version, Some("HTTP/1.1" | "HTTP/1.0"))
4453            || !code.is_some_and(|code| (100..600).contains(&code))
4454        {
4455            return Err(HealthProbeError::bad_answer(format!(
4456                "invalid HTTP status: {status}"
4457            )));
4458        }
4459        let code = code.expect("validated status code");
4460        response_status = status.clone();
4461        let mut length = None;
4462        let mut chunked = false;
4463        loop {
4464            let line = http_line(&mut reader, &mut budget).await?;
4465            if line.is_empty() {
4466                break;
4467            }
4468            if let Some((name, value)) = line.split_once(':') {
4469                if name.eq_ignore_ascii_case("content-length") {
4470                    length = Some(value.trim().parse::<u64>().map_err(|_| {
4471                        HealthProbeError::bad_answer("invalid HTTP Content-Length")
4472                    })?);
4473                } else if name.eq_ignore_ascii_case("transfer-encoding") {
4474                    chunked = value.trim().eq_ignore_ascii_case("chunked");
4475                }
4476            }
4477        }
4478        if chunked {
4479            while body.len() < 200 {
4480                let line = http_line(&mut reader, &mut budget).await?;
4481                let size = u64::from_str_radix(line.split(';').next().unwrap_or("").trim(), 16)
4482                    .map_err(|_| HealthProbeError::bad_answer("invalid HTTP chunk size"))?;
4483                if size == 0 {
4484                    break;
4485                }
4486                let count = size.min((200 - body.len()) as u64) as usize;
4487                let start = body.len();
4488                (&mut reader)
4489                    .take(count as u64)
4490                    .read_to_end(&mut body)
4491                    .await
4492                    .map_err(|error| {
4493                        HealthProbeError::no_answer(format!("HTTP body read failed: {error}"))
4494                    })?;
4495                if body.len() - start != count {
4496                    return Err(HealthProbeError::bad_answer("truncated HTTP chunk"));
4497                }
4498                if size > count as u64 || body.len() == 200 {
4499                    break;
4500                }
4501                if !http_line(&mut reader, &mut budget).await?.is_empty() {
4502                    return Err(HealthProbeError::bad_answer("invalid HTTP chunk delimiter"));
4503                }
4504            }
4505        } else {
4506            reader
4507                .take(length.unwrap_or(200).min(200))
4508                .read_to_end(&mut body)
4509                .await
4510                .map_err(|error| {
4511                    HealthProbeError::no_answer(format!("HTTP body read failed: {error}"))
4512                })?;
4513        }
4514        if (200..300).contains(&code) {
4515            Ok(HealthReport::ok())
4516        } else {
4517            Err(HealthProbeError::bad_answer(
4518                "HTTP health endpoint returned non-2xx",
4519            ))
4520        }
4521    };
4522    let mut result = timeout(deadline, probe).await.unwrap_or_else(|_| {
4523        Err(HealthProbeError::no_answer(format!(
4524            "HTTP probe timed out after {deadline:?}"
4525        )))
4526    });
4527    if let Err(error) = &mut result {
4528        if !response_status.is_empty() {
4529            error.message = format!(
4530                "{}; {response_status}: {}",
4531                error.message,
4532                String::from_utf8_lossy(&body)
4533            );
4534        }
4535    }
4536    result
4537}
4538
4539async fn http_line(
4540    reader: &mut tokio::io::BufReader<tokio::net::TcpStream>,
4541    remaining: &mut usize,
4542) -> Result<String, HealthProbeError> {
4543    use tokio::io::{AsyncBufReadExt, AsyncReadExt};
4544    let mut line = Vec::new();
4545    (&mut *reader)
4546        .take(*remaining as u64)
4547        .read_until(b'\n', &mut line)
4548        .await
4549        .map_err(|error| {
4550            HealthProbeError::no_answer(format!("HTTP header read failed: {error}"))
4551        })?;
4552    *remaining -= line.len();
4553    if !line.ends_with(b"\r\n") {
4554        return Err(HealthProbeError::bad_answer(
4555            "HTTP headers are incomplete or exceed 16 KiB",
4556        ));
4557    }
4558    line.truncate(line.len() - 2);
4559    String::from_utf8(line).map_err(|_| HealthProbeError::bad_answer("HTTP header is not UTF-8"))
4560}
4561
4562async fn probe_module_health(
4563    module_id: &str,
4564    runtime: &SupervisorRuntimeConfig,
4565    drain_deadline: Option<Instant>,
4566) -> Result<HealthReport, HealthProbeError> {
4567    let Some(forwarding) = runtime.forwarding.as_ref() else {
4568        return Err(HealthProbeError::misconfigured(
4569            "supervisor was not configured with a forwarding table",
4570        ));
4571    };
4572    let probe_started_at = Instant::now();
4573    let mut deadline = probe_started_at + runtime.health.deadline;
4574    if let Some(drain_deadline) = drain_deadline {
4575        deadline = deadline.min(drain_deadline);
4576    }
4577    let pending = if drain_deadline.is_some() {
4578        forwarding.begin_drain_health_probe_rpc_for(
4579            module_id,
4580            MODULE_CONTROL_OP_HEALTH_CHECK,
4581            probe_started_at,
4582            deadline,
4583        )
4584    } else {
4585        forwarding.begin_health_probe_rpc_for(
4586            module_id,
4587            MODULE_CONTROL_OP_HEALTH_CHECK,
4588            probe_started_at,
4589            deadline,
4590        )
4591    }
4592    .map_err(|err| {
4593        // The endpoint is not registered, so there is no live control lane to
4594        // ask. That is the module being absent, not slow.
4595        HealthProbeError::lane_dead(format!("failed to begin health.check RPC: {err}"))
4596    })?;
4597    await_health_probe(forwarding, pending, deadline, runtime.health.deadline).await
4598}
4599
4600/// [`probe_module_health`] for one endpoint rather than the id's active one.
4601///
4602/// A swap probes two processes that no by-id lookup reaches: its candidate
4603/// before cutover, and its superseded incumbent (for busy gauges) while the
4604/// incumbent drains. `deadline_cap` bounds the probe the way a drain deadline
4605/// bounds the by-id drain probe.
4606async fn probe_endpoint_health(
4607    endpoint: crate::ModuleEndpointId,
4608    runtime: &SupervisorRuntimeConfig,
4609    deadline_cap: Option<Instant>,
4610) -> Result<HealthReport, HealthProbeError> {
4611    let Some(forwarding) = runtime.forwarding.as_ref() else {
4612        return Err(HealthProbeError::misconfigured(
4613            "supervisor was not configured with a forwarding table",
4614        ));
4615    };
4616    let probe_started_at = Instant::now();
4617    let mut deadline = probe_started_at + runtime.health.deadline;
4618    if let Some(cap) = deadline_cap {
4619        deadline = deadline.min(cap);
4620    }
4621    let pending = forwarding
4622        .begin_endpoint_health_probe_rpc_for(
4623            endpoint,
4624            MODULE_CONTROL_OP_HEALTH_CHECK,
4625            probe_started_at,
4626            deadline,
4627        )
4628        .map_err(|err| {
4629            HealthProbeError::lane_dead(format!("failed to begin health.check RPC: {err}"))
4630        })?;
4631    await_health_probe(forwarding, pending, deadline, runtime.health.deadline).await
4632}
4633
4634/// Send a begun health probe and classify its answer.
4635async fn await_health_probe(
4636    forwarding: &ForwardingTable,
4637    pending: PendingModuleControlRpc,
4638    deadline: Instant,
4639    probe_budget: Duration,
4640) -> Result<HealthReport, HealthProbeError> {
4641    let PendingModuleControlRpc {
4642        endpoint,
4643        module_sink,
4644        negotiated_ver,
4645        corr,
4646        receiver,
4647    } = pending;
4648    let body = serde_json::to_vec(&ModuleControlRequest::HealthCheck {}).map_err(|err| {
4649        HealthProbeError::misconfigured(format!("failed to encode health.check: {err}"))
4650    })?;
4651    let frame = Frame::build_with_version(
4652        negotiated_ver,
4653        FrameType::Request,
4654        control_flags(),
4655        0,
4656        0,
4657        corr,
4658        body,
4659    )
4660    .map_err(|err| {
4661        HealthProbeError::misconfigured(format!("failed to build health.check frame: {err}"))
4662    })?;
4663
4664    // The enqueue itself must be bounded by the probe deadline: FrameSink.send
4665    // blocks waiting for capacity when the module's egress queue is full, and an
4666    // unbounded await here freezes the whole supervision actor (it stops polling
4667    // Child::wait and supervisor commands), making the module unrecoverable
4668    // in-band. On timeout the probe fails like any transport failure.
4669    match timeout_at(deadline, module_sink.send(frame)).await {
4670        Ok(Ok(())) => {}
4671        Ok(Err(err)) => {
4672            let _ = forwarding.cancel_module_control_rpc(endpoint, corr);
4673            // A closed sink means the module's egress channel is gone -- the
4674            // receiving half is dropped when its connection tears down. Proof.
4675            return Err(HealthProbeError::lane_dead(format!(
4676                "failed to send health.check: {err}"
4677            )));
4678        }
4679        Err(_elapsed) => {
4680            let _ = forwarding.cancel_module_control_rpc(endpoint, corr);
4681            // A full egress queue means the module is not draining its socket, which
4682            // is consistent with a wedged module AND with one whose reader is merely
4683            // starved. Silence, not proof.
4684            return Err(HealthProbeError::no_answer(
4685                "health.check send timed out before enqueue (module egress full)",
4686            ));
4687        }
4688    }
4689
4690    match timeout_at(deadline, receiver).await {
4691        // Each arm records WHAT WAS OBSERVED. Four of them are the module
4692        // demonstrably answering -- rejected, non-health, malformed, wrong op --
4693        // and those prove it is alive even though the probe failed.
4694        Ok(Ok(ModuleControlRpcOutcome::Response(response))) => {
4695            response.health_report().ok_or_else(|| {
4696                HealthProbeError::bad_answer("health.check RPC returned a non-health response")
4697            })
4698        }
4699        Ok(Ok(ModuleControlRpcOutcome::Rejected(body))) => Err(HealthProbeError::bad_answer(
4700            format!("health.check rejected: {}", body.message),
4701        )),
4702        Ok(Ok(ModuleControlRpcOutcome::ModuleGone(message))) => {
4703            Err(HealthProbeError::lane_dead(message))
4704        }
4705        Ok(Ok(ModuleControlRpcOutcome::MalformedResponse(message))) => {
4706            Err(HealthProbeError::bad_answer(message))
4707        }
4708        Ok(Ok(ModuleControlRpcOutcome::UnexpectedOp { expected, actual })) => {
4709            Err(HealthProbeError::bad_answer(format!(
4710                "expected module-control op '{expected}', got '{actual}'"
4711            )))
4712        }
4713        // A reply that crosses the deadline before this waiter observes it is
4714        // still proof of life. The forwarding path records its end-to-end latency
4715        // before delivering this classification.
4716        Ok(Ok(ModuleControlRpcOutcome::DeadlineElapsed)) => Err(HealthProbeError::bad_answer(
4717            "module answered health.check after its daemon deadline",
4718        )),
4719        Ok(Err(_)) => Err(HealthProbeError::misconfigured(
4720            "health.check waiter was canceled before the module responded",
4721        )),
4722        Err(_) => {
4723            let _ = forwarding.tombstone_health_probe_rpc(endpoint, corr);
4724            Err(HealthProbeError::no_answer(format!(
4725                "module did not answer health.check within {probe_budget:?}"
4726            )))
4727        }
4728    }
4729}
4730
4731#[allow(clippy::too_many_arguments)]
4732async fn handle_health_report(
4733    spec: &ModuleSpec,
4734    runtime: &SupervisorRuntimeConfig,
4735    registry: &Registry,
4736    process_liveness: &SupervisorProcessLiveness,
4737    snapshot: &SharedSnapshot,
4738    child: &mut Option<SupervisedChild>,
4739    report: HealthReport,
4740    now_ms: u64,
4741) {
4742    let status = supervisor_health_status(report.status);
4743    let detail = report.detail.clone();
4744    let metrics = truncate_health_metrics(report.metrics);
4745    let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4746        state.health.status = status;
4747        state.health.last_probe_ms = Some(now_ms);
4748        state.health.detail = detail.clone();
4749        state.health.metrics = metrics.clone();
4750        state.health.consecutive_failures = 0;
4751    });
4752
4753    let action = match report.status {
4754        HealthStatus::Ok => return,
4755        HealthStatus::Degraded => runtime.health.on_degraded,
4756        HealthStatus::Failing => runtime.health.on_failing,
4757    };
4758    apply_l3_health_action(
4759        spec,
4760        runtime,
4761        registry,
4762        process_liveness,
4763        snapshot,
4764        child,
4765        status,
4766        detail.as_deref(),
4767        action,
4768        now_ms,
4769    )
4770    .await;
4771}
4772
4773#[allow(clippy::too_many_arguments)]
4774async fn handle_health_probe_failure(
4775    spec: &ModuleSpec,
4776    runtime: &SupervisorRuntimeConfig,
4777    registry: &Registry,
4778    process_liveness: &SupervisorProcessLiveness,
4779    snapshot: &SharedSnapshot,
4780    child: &mut Option<SupervisedChild>,
4781    err: HealthProbeError,
4782    now_ms: u64,
4783) {
4784    let threshold = runtime.health.failure_threshold.max(1);
4785    let mut failures = 0;
4786    // Carry the evidence class into the operator-visible detail. Without it,
4787    // "module did not answer within 5s" and "the control lane is gone" are two
4788    // prose strings in the same field, and the reader has to know the codebase to
4789    // tell which one is proof of anything.
4790    let detail = format!("[{}] {err}", err.label());
4791    let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4792        // A failed wire probe invalidates the last report, even before the
4793        // restart threshold. HTTP probes already mark failures as Failing.
4794        if state.spawned_protocol.unwrap_or(spec.protocol) == ModuleProtocol::Subc {
4795            state.health.status = SupervisorHealthStatus::Unknown;
4796        }
4797        state.health.last_probe_ms = Some(now_ms);
4798        state.health.consecutive_failures = state.health.consecutive_failures.saturating_add(1);
4799        state.health.detail = Some(detail.clone());
4800        state.health.metrics = None;
4801        failures = state.health.consecutive_failures;
4802    });
4803
4804    if failures < threshold {
4805        warn!(
4806            module_id = %spec.module_id,
4807            consecutive_failures = failures,
4808            threshold,
4809            evidence = err.label(),
4810            detail = %detail,
4811            "health.check probe failed"
4812        );
4813        return;
4814    }
4815
4816    let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4817        state.state = ModuleState::Unresponsive;
4818        state.health.status = SupervisorHealthStatus::Unresponsive;
4819    });
4820    // The evidence class is logged at the kill site because this is the line an
4821    // operator reads after an unexplained restart. A streak of `no-answer` under
4822    // machine load is the known false-positive shape; a `lane-dead` is not.
4823    if runtime.health.critical {
4824        error!(
4825            module_id = %spec.module_id,
4826            status = "unresponsive",
4827            evidence = err.label(),
4828            detail = %detail,
4829            "critical module health alert"
4830        );
4831    } else {
4832        warn!(
4833            module_id = %spec.module_id,
4834            status = "unresponsive",
4835            evidence = err.label(),
4836            detail = %detail,
4837            "module health threshold breached"
4838        );
4839    }
4840    if let Err(err) = health_restart_child(
4841        spec,
4842        runtime,
4843        registry,
4844        process_liveness,
4845        snapshot,
4846        child,
4847        SupervisorHealthStatus::Unresponsive,
4848        Some(&detail),
4849        now_ms,
4850    )
4851    .await
4852    {
4853        error!(module_id = %spec.module_id, error = %err, "health-triggered restart failed");
4854    }
4855}
4856
4857#[allow(clippy::too_many_arguments)]
4858async fn apply_l3_health_action(
4859    spec: &ModuleSpec,
4860    runtime: &SupervisorRuntimeConfig,
4861    registry: &Registry,
4862    process_liveness: &SupervisorProcessLiveness,
4863    snapshot: &SharedSnapshot,
4864    child: &mut Option<SupervisedChild>,
4865    status: SupervisorHealthStatus,
4866    detail: Option<&str>,
4867    action: HealthAction,
4868    now_ms: u64,
4869) {
4870    record_health_action(snapshot, &spec.module_id, action.to_string(), now_ms);
4871    match action {
4872        HealthAction::Report => {
4873            info!(
4874                module_id = %spec.module_id,
4875                status = ?status,
4876                detail,
4877                "module reported non-ok health"
4878            );
4879        }
4880        HealthAction::Alert => {
4881            error!(
4882                module_id = %spec.module_id,
4883                status = ?status,
4884                detail,
4885                "module health alert"
4886            );
4887        }
4888        HealthAction::Restart => {
4889            if let Err(err) = health_restart_child(
4890                spec,
4891                runtime,
4892                registry,
4893                process_liveness,
4894                snapshot,
4895                child,
4896                status,
4897                detail,
4898                now_ms,
4899            )
4900            .await
4901            {
4902                error!(module_id = %spec.module_id, error = %err, "health-triggered restart failed");
4903            }
4904        }
4905    }
4906}
4907
4908#[allow(clippy::too_many_arguments)]
4909async fn health_restart_child(
4910    spec: &ModuleSpec,
4911    runtime: &SupervisorRuntimeConfig,
4912    registry: &Registry,
4913    process_liveness: &SupervisorProcessLiveness,
4914    snapshot: &SharedSnapshot,
4915    child: &mut Option<SupervisedChild>,
4916    status: SupervisorHealthStatus,
4917    detail: Option<&str>,
4918    now_ms: u64,
4919) -> Result<(), SuperviseError> {
4920    let (enabled, schedule) = {
4921        let mut state = lock_snapshot(snapshot)?;
4922        let enabled = state.enabled;
4923        let schedule = if enabled {
4924            state.next_crash_restart(&runtime.restart_policy, Instant::now())
4925        } else {
4926            None
4927        };
4928        (enabled, schedule)
4929    };
4930
4931    if !enabled {
4932        return Err(SuperviseError::Disabled {
4933            module_id: spec.module_id.clone(),
4934        });
4935    }
4936
4937    if schedule.is_none() {
4938        record_health_action(snapshot, &spec.module_id, "failed".to_string(), now_ms);
4939        error!(
4940            module_id = %spec.module_id,
4941            status = ?status,
4942            detail,
4943            max_restarts = runtime.restart_policy.max_restarts,
4944            window_secs = runtime.restart_policy.window.as_secs(),
4945            reason = %runtime.restart_policy.budget_exhausted_detail(),
4946            "health restart budget exhausted; marking module failed"
4947        );
4948        let stop_notice = begin_forwarding_drain_if_configured(
4949            spec,
4950            runtime,
4951            registry,
4952            snapshot,
4953            Some(true),
4954            RouteCloseReason::Disable,
4955        )
4956        .await?;
4957        update_snapshot(snapshot, Some(&spec.module_id), |state| {
4958            state.drain_disposition_detail = Some(runtime.restart_policy.budget_exhausted_detail());
4959        })?;
4960        drain_optional_child(
4961            &spec.module_id,
4962            spec.protocol,
4963            stop_notice,
4964            registry,
4965            runtime.forwarding.as_deref(),
4966            snapshot,
4967            &runtime.terminal_ring,
4968            &runtime.spawn_events,
4969            child,
4970            runtime.drain_timeout,
4971            ModuleState::Failed,
4972            Some(true),
4973        )
4974        .await?;
4975        process_liveness.untrack_if_current(&spec.module_id, snapshot);
4976        return Ok(());
4977    }
4978
4979    let schedule = schedule.expect("a health restart must have a crash-restart schedule");
4980    let mut restart_count = 0;
4981    update_snapshot(snapshot, Some(&spec.module_id), |state| {
4982        restart_count = state.crash_restarts.len();
4983        state.state = ModuleState::Unresponsive;
4984        state.health.status = status;
4985        state.health.last_action = Some(HealthAction::Restart.to_string());
4986        state.health.last_action_ms = Some(now_ms);
4987    })?;
4988    warn!(
4989        module_id = %spec.module_id,
4990        status = ?status,
4991        detail,
4992        restart_count,
4993        restart_in_window = schedule.restart_in_window,
4994        delay_ms = schedule.delay.as_millis() as u64,
4995        "health-triggered module restart"
4996    );
4997
4998    let stop_notice = begin_forwarding_drain_if_configured(
4999        spec,
5000        runtime,
5001        registry,
5002        snapshot,
5003        Some(true),
5004        RouteCloseReason::Restart,
5005    )
5006    .await?;
5007    drain_optional_child(
5008        &spec.module_id,
5009        spec.protocol,
5010        stop_notice,
5011        registry,
5012        runtime.forwarding.as_deref(),
5013        snapshot,
5014        &runtime.terminal_ring,
5015        &runtime.spawn_events,
5016        child,
5017        runtime.drain_timeout,
5018        ModuleState::Restarting,
5019        Some(true),
5020    )
5021    .await?;
5022    schedule_respawn(
5023        runtime,
5024        snapshot,
5025        &spec.module_id,
5026        schedule.delay,
5027        RespawnKind::Spawn,
5028    )
5029}
5030
5031fn cancel_deferred_reload(runtime: &SupervisorRuntimeConfig, module_id: &str, reason: &str) {
5032    if let Some(reply) = runtime
5033        .deferred_reload_reply
5034        .lock()
5035        .unwrap_or_else(|p| p.into_inner())
5036        .take()
5037    {
5038        let _ = reply.send(Err(SuperviseError::ReloadFailed {
5039            module_id: module_id.to_string(),
5040            reason: reason.to_string(),
5041        }));
5042    }
5043}
5044
5045fn schedule_respawn(
5046    runtime: &SupervisorRuntimeConfig,
5047    snapshot: &SharedSnapshot,
5048    module_id: &str,
5049    delay: Duration,
5050    kind: RespawnKind,
5051) -> Result<(), SuperviseError> {
5052    cancel_deferred_reload(runtime, module_id, "respawn superseded by another restart");
5053    update_snapshot(snapshot, Some(module_id), |state| {
5054        state.respawn_pending = true
5055    })?;
5056    *runtime
5057        .scheduled_respawn
5058        .lock()
5059        .unwrap_or_else(|p| p.into_inner()) = Some(PendingRespawn {
5060        deadline: Instant::now() + delay,
5061        kind,
5062    });
5063    Ok(())
5064}
5065
5066fn record_health_action(snapshot: &SharedSnapshot, module_id: &str, action: String, now_ms: u64) {
5067    let _ = update_snapshot(snapshot, Some(module_id), |state| {
5068        state.health.last_action = Some(action);
5069        state.health.last_action_ms = Some(now_ms);
5070    });
5071}
5072
5073fn supervisor_health_status(status: HealthStatus) -> SupervisorHealthStatus {
5074    match status {
5075        HealthStatus::Ok => SupervisorHealthStatus::Ok,
5076        HealthStatus::Degraded => SupervisorHealthStatus::Degraded,
5077        HealthStatus::Failing => SupervisorHealthStatus::Failing,
5078    }
5079}
5080
5081/// Caps the metrics blob stored in the cached supervisor snapshot, which is
5082/// returned to every `supervisor.list` and `supervisor.health` caller.
5083///
5084/// This cap is deliberately NOT applied on the one-shot `supervisor.health_probe`
5085/// path: that request exists to return a module's complete metrics object, and
5086/// `ck health <module-id>` documents it as the way to see what the cached view
5087/// truncates. The asymmetry is the feature.
5088///
5089/// So a new caller must decide which side it is on rather than assume the cap is
5090/// universal. Reaching for it on a fresh-probe path would silently reintroduce
5091/// the truncation that path exists to avoid.
5092fn truncate_health_metrics(metrics: Option<Value>) -> Option<Value> {
5093    let metrics = metrics?;
5094    match serde_json::to_vec(&metrics) {
5095        Ok(encoded) if encoded.len() > MAX_HEALTH_METRICS_BYTES => Some(serde_json::json!({
5096            "truncated": true,
5097            "original_bytes": encoded.len(),
5098        })),
5099        Ok(_) | Err(_) => Some(metrics),
5100    }
5101}
5102
5103/// Spread health probes so a fleet-wide restart does not converge them.
5104///
5105/// The delay is derived from the module id and probe index rather than a random
5106/// source, so it is deterministic per module: a module keeps its own offset
5107/// across daemon restarts instead of re-rolling into a collision.
5108fn jittered_health_delay(module_id: &str, probe_index: u64, cadence: Duration) -> Duration {
5109    if cadence.is_zero() {
5110        return Duration::ZERO;
5111    }
5112    let cadence_ms = cadence.as_millis() as u64;
5113    // This early return is REDUNDANT, deliberately, and a mutation run will show
5114    // it surviving removal. Recording why here so the next person to notice does
5115    // not have to re-derive it:
5116    //
5117    // - It is unreachable in practice. `positive_millis` in daemon_config rejects
5118    //   a zero cadence and builds the Duration from whole milliseconds, so a
5119    //   sub-millisecond cadence cannot come from config.
5120    // - Even if reached it changes no answer. The `.max(1)` below makes the span
5121    //   1, and `hash % 1` is 0, so the fall-through returns `cadence` unchanged
5122    //   -- exactly what this returns.
5123    //
5124    // Kept as a guard against a future widening of the config parser (accepting
5125    // microseconds, say), which would make the sub-millisecond case reachable.
5126    // The `.max(1)` is the load-bearing half TODAY: remove it and the modulo
5127    // divides by zero. Remove this and nothing changes.
5128    if cadence_ms == 0 {
5129        return cadence;
5130    }
5131    // Note that this never returns less than one cadence, including for the FIRST
5132    // probe. So a freshly registered module reports health `unknown` for a full
5133    // cadence plus jitter -- 30-33s at the default -- no matter how quickly it is
5134    // ready to answer.
5135    //
5136    // That is a property of the supervisor's schedule, not of any module: an
5137    // operator watching a restart sees `unknown` and cannot tell it from a module
5138    // that is slow to warm. Measured on two unrelated modules, both flipping to
5139    // `ok` between 22s and 32s after restart.
5140    //
5141    // Left as-is because spreading the first probe is what keeps a fleet-wide
5142    // restart from firing fourteen simultaneous probes into a cold machine. The
5143    // alternative -- probe at t+0 and jitter only from the second onward -- trades
5144    // that thundering herd for a faster first reading.
5145    let jitter_span = (cadence_ms / 10).max(1);
5146    let hash = module_id.as_bytes().iter().fold(
5147        probe_index.wrapping_mul(0x9E37_79B9_7F4A_7C15),
5148        |acc, byte| {
5149            acc.wrapping_mul(1099511628211)
5150                .wrapping_add(u64::from(*byte))
5151        },
5152    );
5153    cadence + Duration::from_millis(hash % jitter_span)
5154}
5155
5156#[cfg(test)]
5157mod tests {
5158    use super::*;
5159
5160    #[test]
5161    fn readding_a_module_clears_its_rescan_removal_tombstone() {
5162        let handle = SupervisorHandle::new();
5163        let module_id = "readded-tombstone";
5164        handle.record_rescan_removal(module_id);
5165        assert!(handle.removal_tombstone_age_ms(module_id).is_some());
5166
5167        handle.apply_identity_configuration(&ModuleSpec {
5168            module_id: module_id.to_string(),
5169            program: PathBuf::from("/test/module"),
5170            args: Vec::new(),
5171            env: Vec::new(),
5172            reserved: false,
5173            reserved_prefixes: Vec::new(),
5174            protocol: ModuleProtocol::Subc,
5175            overlap: Default::default(),
5176        });
5177
5178        assert!(
5179            handle.removal_tombstone_age_ms(module_id).is_none(),
5180            "a re-added module must not retain a stale removal tombstone"
5181        );
5182    }
5183
5184    /// What one module's owner looked like from the control plane at the
5185    /// instant after its first process was spawned.
5186    #[derive(Debug, PartialEq, Eq)]
5187    struct OwnerInSpawnWindow {
5188        module_id: String,
5189        configured: bool,
5190        on_roster: bool,
5191        admission_refusal: Option<&'static str>,
5192    }
5193
5194    /// A supervised module's process can connect, register, sync its scopes
5195    /// and describe them as soon as it is spawned, which is BEFORE the
5196    /// supervisor puts the module on the roster. In that window the owner must
5197    /// already read as configured, so a scoped `route.open` against it is
5198    /// refused as retryable `scope_not_synced` and not as terminal
5199    /// `scope_not_live` ("will never sync").
5200    ///
5201    /// The hook runs in exactly that window on every path that takes on a new
5202    /// module, so no race with a real child is needed: `on_roster: false`
5203    /// proves each observation was taken before the roster insert.
5204    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5205    async fn a_new_module_is_configured_before_its_first_process_can_register() {
5206        use crate::scopes::ScopeTable;
5207        use subc_protocol::{error_codes, scope::ScopeSelector, Principal};
5208
5209        let dir = cortexkit_test_support::ScratchDir::new("configured-before-spawn");
5210        let stub = |module_id: &str, program: PathBuf| ModuleSpec {
5211            module_id: module_id.to_string(),
5212            program,
5213            args: Vec::new(),
5214            env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
5215                .into_iter()
5216                .map(|key| (key.to_string(), dir.path().display().to_string()))
5217                .chain([("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string())])
5218                .collect(),
5219            reserved: false,
5220            reserved_prefixes: Vec::new(),
5221            protocol: ModuleProtocol::Subc,
5222            overlap: Default::default(),
5223        };
5224        let live = super::terminal_history_tests::fake_aft_stub_path();
5225        let missing = dir.path().join("definitely-missing-module");
5226
5227        let handle = SupervisorHandle::new();
5228        let mut supervisor =
5229            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5230                .with_handle(handle.clone());
5231        let observed = Arc::new(Mutex::new(Vec::<OwnerInSpawnWindow>::new()));
5232        let hook_handle = handle.clone();
5233        let hook_observed = Arc::clone(&observed);
5234        supervisor.test_after_first_spawn = AfterFirstSpawnHook(Some(Arc::new(move |module_id| {
5235            // Exactly what the control plane computes for a scoped route.open
5236            // naming this module as the owner of a scope it has not synced.
5237            let configured = hook_handle.is_configured(module_id);
5238            let selector = ScopeSelector {
5239                owner: Principal::Reserved {
5240                    module_id: module_id.to_string(),
5241                },
5242                scope_ref: "s".to_string(),
5243                scope_epoch: Some(1),
5244            };
5245            let carrier = Principal::Reserved {
5246                module_id: "carrier".to_string(),
5247            };
5248            let admission_refusal = match ScopeTable::new(Vec::<String>::new())
5249                .admit(&carrier, module_id, &selector, configured)
5250            {
5251                Ok(_) => None,
5252                Err(refusal) => Some(refusal.code),
5253            };
5254            hook_observed.lock().unwrap().push(OwnerInSpawnWindow {
5255                module_id: module_id.to_string(),
5256                configured,
5257                on_roster: hook_handle.get(module_id).is_some(),
5258                admission_refusal,
5259            });
5260        })));
5261
5262        let plain = supervisor.spawn(stub("plain", live.clone())).unwrap();
5263        let configured = supervisor
5264            .supervise_configured(stub("configured", live.clone()), true)
5265            .unwrap();
5266        let with_health = supervisor
5267            .supervise_configured_with_health(
5268                stub("with-health", live.clone()),
5269                true,
5270                HealthConfig::default(),
5271                None,
5272                RestartPolicy::default(),
5273            )
5274            .unwrap();
5275        // The failed-spawn path still puts the module on the roster (as
5276        // failed), so it is configured throughout.
5277        let failed = supervisor
5278            .supervise_configured_with_health(
5279                stub("failed-spawn", missing.clone()),
5280                true,
5281                HealthConfig::default(),
5282                None,
5283                RestartPolicy::default(),
5284            )
5285            .unwrap();
5286        // A failed plain `spawn` puts nothing on the roster, so its mark is
5287        // taken back once the spawn has failed.
5288        assert!(supervisor.spawn(stub("spawn-error", missing)).is_err());
5289
5290        let expected = [
5291            "plain",
5292            "configured",
5293            "with-health",
5294            "failed-spawn",
5295            "spawn-error",
5296        ]
5297        .into_iter()
5298        .map(|module_id| OwnerInSpawnWindow {
5299            module_id: module_id.to_string(),
5300            configured: true,
5301            on_roster: false,
5302            admission_refusal: Some(error_codes::SCOPE_NOT_SYNCED),
5303        })
5304        .collect::<Vec<_>>();
5305        assert_eq!(*observed.lock().unwrap(), expected);
5306
5307        for module_id in ["plain", "configured", "with-health", "failed-spawn"] {
5308            assert!(
5309                handle.get(module_id).is_some(),
5310                "{module_id} is on the roster"
5311            );
5312            assert!(
5313                handle.is_configured(module_id),
5314                "{module_id} stays configured"
5315            );
5316        }
5317        assert!(handle.get("spawn-error").is_none());
5318        assert!(
5319            !handle.is_configured("spawn-error"),
5320            "a plain spawn that failed must not leave its module marked configured"
5321        );
5322
5323        // Leaving the roster clears the mark with it.
5324        handle.retire("failed-spawn");
5325        assert!(!handle.is_configured("failed-spawn"));
5326
5327        for module in [plain, configured, with_health] {
5328            module.stop().await.unwrap();
5329        }
5330        drop(failed);
5331    }
5332
5333    fn stale_process_snapshot(state: ModuleState, enabled: bool) -> SharedSnapshot {
5334        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(state, enabled)));
5335        update_snapshot(&snapshot, Some("stale-process-facts"), |snapshot| {
5336            snapshot.process_alive = true;
5337            snapshot.pid = Some(41);
5338            snapshot.spawned_at_ms = Some(42);
5339            snapshot.spawned_from = Some(PathBuf::from("/spawned/module"));
5340            snapshot.spawned_file_identity = Some(SpawnedFileIdentity {
5341                device: 43,
5342                inode: 44,
5343            });
5344        })
5345        .unwrap();
5346        snapshot
5347    }
5348
5349    fn assert_snapshot_process_facts_cleared(snapshot: &SharedSnapshot) {
5350        let snapshot = lock_snapshot(snapshot).unwrap();
5351        assert!(!snapshot.process_alive);
5352        assert_eq!(snapshot.pid, None);
5353        assert_eq!(snapshot.spawned_at_ms, None);
5354        assert_eq!(snapshot.spawned_from, None);
5355        assert_eq!(snapshot.spawned_file_identity, None);
5356    }
5357
5358    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5359    async fn failed_enable_spawn_clears_preexisting_current_process_facts() {
5360        let supervisor =
5361            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5362        let mut runtime = supervisor.runtime_config();
5363        runtime.test_seed_stale_facts_before_enable_spawn = true;
5364        let snapshot = stale_process_snapshot(ModuleState::Disabled, false);
5365        let mut child = None;
5366        let spec = ModuleSpec {
5367            module_id: "failed-enable-clears-facts".to_string(),
5368            program: PathBuf::from("/definitely/missing/failed-enable-module"),
5369            args: Vec::new(),
5370            env: Vec::new(),
5371            reserved: false,
5372            reserved_prefixes: Vec::new(),
5373            protocol: ModuleProtocol::Subc,
5374            overlap: Default::default(),
5375        };
5376
5377        let result = set_child_enabled(
5378            &spec,
5379            &runtime,
5380            &supervisor.registry,
5381            &supervisor.process_liveness,
5382            &snapshot,
5383            &mut child,
5384            true,
5385        )
5386        .await;
5387
5388        assert!(matches!(result, Err(SuperviseError::Spawn { .. })));
5389        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
5390        assert_snapshot_process_facts_cleared(&snapshot);
5391    }
5392
5393    #[tokio::test]
5394    async fn start_revives_stranded_restarting_but_not_pending_backoff() {
5395        let supervisor =
5396            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5397        let runtime = supervisor.runtime_config();
5398        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(
5399            ModuleState::Restarting,
5400            true,
5401        )));
5402        let spec = ModuleSpec {
5403            module_id: "start-stranded-restarting".to_string(),
5404            program: super::terminal_history_tests::fake_aft_stub_path(),
5405            args: Vec::new(),
5406            env: vec![("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string())],
5407            reserved: false,
5408            reserved_prefixes: Vec::new(),
5409            protocol: ModuleProtocol::None,
5410            overlap: Default::default(),
5411        };
5412        let mut child = None;
5413        lock_snapshot(&snapshot).unwrap().respawn_pending = true;
5414        assert!(!super::set_child_enabled(
5415            &spec,
5416            &runtime,
5417            &Registry::default(),
5418            &supervisor.process_liveness,
5419            &snapshot,
5420            &mut child,
5421            true
5422        )
5423        .await
5424        .unwrap());
5425        assert!(child.is_none());
5426        lock_snapshot(&snapshot).unwrap().respawn_pending = false;
5427        assert!(super::set_child_enabled(
5428            &spec,
5429            &runtime,
5430            &Registry::default(),
5431            &supervisor.process_liveness,
5432            &snapshot,
5433            &mut child,
5434            true
5435        )
5436        .await
5437        .unwrap());
5438        assert_eq!(
5439            lock_snapshot(&snapshot).unwrap().state,
5440            ModuleState::Running
5441        );
5442        let mut child = child.unwrap();
5443        child.start_kill().unwrap();
5444        child.wait().await.unwrap();
5445    }
5446
5447    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5448    async fn failed_reload_spawn_clears_current_process_facts() {
5449        let supervisor =
5450            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5451        let mut runtime = supervisor.runtime_config();
5452        runtime.restart_policy = RestartPolicy::new(0, Duration::ZERO);
5453        let snapshot = stale_process_snapshot(ModuleState::Running, true);
5454        let mut child = None;
5455        let spec = ModuleSpec {
5456            module_id: "failed-reload-clears-facts".to_string(),
5457            program: PathBuf::from("/unused/failed-reload-module"),
5458            args: Vec::new(),
5459            env: Vec::new(),
5460            reserved: false,
5461            reserved_prefixes: Vec::new(),
5462            protocol: ModuleProtocol::Subc,
5463            overlap: Default::default(),
5464        };
5465
5466        let result = handle_reload_spawn_failure(
5467            &spec,
5468            &runtime,
5469            &supervisor.process_liveness,
5470            &snapshot,
5471            &mut child,
5472            "forced reload spawn failure".to_string(),
5473        )
5474        .await;
5475
5476        assert!(matches!(result, Err(SuperviseError::ReloadFailed { .. })));
5477        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
5478        assert_snapshot_process_facts_cleared(&snapshot);
5479    }
5480
5481    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5482    async fn dropping_a_module_with_an_active_monitor_clears_current_process_facts() {
5483        let supervisor =
5484            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5485        let snapshot = stale_process_snapshot(ModuleState::Running, true);
5486        let module = supervisor.supervised_module(
5487            ModuleSpec {
5488                module_id: "drop-clears-facts".to_string(),
5489                program: PathBuf::from("/unused/drop-module"),
5490                args: Vec::new(),
5491                env: Vec::new(),
5492                reserved: false,
5493                reserved_prefixes: Vec::new(),
5494                protocol: ModuleProtocol::Subc,
5495                overlap: Default::default(),
5496            },
5497            supervisor.runtime_config(),
5498            Arc::clone(&snapshot),
5499            None,
5500        );
5501        assert!(!module
5502            .inner
5503            .monitor
5504            .lock()
5505            .unwrap()
5506            .as_ref()
5507            .unwrap()
5508            .is_finished());
5509
5510        drop(module);
5511
5512        assert_eq!(
5513            lock_snapshot(&snapshot).unwrap().state,
5514            ModuleState::Stopped
5515        );
5516        assert_snapshot_process_facts_cleared(&snapshot);
5517    }
5518
5519    #[cfg(unix)]
5520    #[tokio::test]
5521    async fn rescan_preserves_running_protocol_until_respawn() {
5522        let dir = cortexkit_test_support::ScratchDir::new("rescan-protocol");
5523        let initial = ModuleSpec {
5524            module_id: "rescan-protocol".into(),
5525            program: PathBuf::from("/bin/sleep"),
5526            args: vec!["60".into()],
5527            env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
5528                .into_iter()
5529                .map(|key| (key.into(), dir.path().to_string_lossy().into_owned()))
5530                .collect(),
5531            reserved: false,
5532            reserved_prefixes: vec![],
5533            protocol: ModuleProtocol::None,
5534            overlap: Default::default(),
5535        };
5536        let supervisor =
5537            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5538        let module = supervisor.spawn(initial.clone()).unwrap();
5539        assert!(module.status().unwrap().live);
5540        let mut next = initial;
5541        next.protocol = ModuleProtocol::Subc;
5542        module
5543            .update_configuration(next.clone(), HealthConfig::default(), None)
5544            .await
5545            .unwrap();
5546        assert!(
5547            module.status().unwrap().live,
5548            "rescan must not require HELLO from the old non-wire process"
5549        );
5550        let runtime = supervisor.runtime_config();
5551        let action = on_child_exit(
5552            &next,
5553            RestartPolicy::default(),
5554            &supervisor.registry,
5555            &module.inner.snapshot,
5556            &runtime.terminal_ring,
5557            &runtime.spawn_events,
5558            &runtime.child_roster,
5559            ExitReport {
5560                kind: ExitKind::Clean,
5561                code: Some(0),
5562                signal: None,
5563                at_ms: unix_ms_now(),
5564            },
5565        )
5566        .await;
5567        assert!(
5568            matches!(action, NextAction::Restart { .. }),
5569            "the old non-wire process's clean exit must restart"
5570        );
5571        module.drain().await.unwrap();
5572    }
5573
5574    #[cfg(unix)]
5575    #[tokio::test]
5576    async fn orphan_identity_matches_path_names_and_shebang_interpreters() {
5577        use std::os::unix::fs::PermissionsExt;
5578        let dir = cortexkit_test_support::ScratchDir::new("spawn-image-identity");
5579        let script = dir.join("module.sh");
5580        std::fs::write(&script, "#!/bin/sh\nwhile :; do sleep 0.1; done\n").unwrap();
5581        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700)).unwrap();
5582        let record_path = dir.join("live-children.json");
5583        let supervisor =
5584            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5585                .with_live_children_record(&record_path);
5586        for (program, args) in [
5587            (PathBuf::from("sleep"), vec!["60".into()]),
5588            (script, vec![]),
5589        ] {
5590            let spec = ModuleSpec {
5591                module_id: "image-identity".into(),
5592                program: program.clone(),
5593                args,
5594                env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
5595                    .into_iter()
5596                    .map(|key| (key.into(), dir.to_string_lossy().into_owned()))
5597                    .collect(),
5598                reserved: false,
5599                reserved_prefixes: vec![],
5600                protocol: ModuleProtocol::None,
5601                overlap: Default::default(),
5602            };
5603            let module = supervisor.spawn(spec).unwrap();
5604            #[cfg(target_os = "macos")]
5605            {
5606                // SETEXEC confirmation is asynchronous; the orphan record must
5607                // identify the final image, never the intermediate trampoline.
5608                let deadline = Instant::now() + Duration::from_secs(5);
5609                while crate::live_children::read_record(&record_path)
5610                    .unwrap()
5611                    .iter()
5612                    .all(|entry| entry.executable.is_none())
5613                {
5614                    assert!(Instant::now() < deadline, "module image was not confirmed");
5615                    tokio::time::sleep(Duration::from_millis(5)).await;
5616                }
5617            }
5618            let entry = crate::live_children::read_record(&record_path)
5619                .unwrap()
5620                .pop()
5621                .unwrap();
5622            let observed = subc_os::Process::open(entry.pid)
5623                .unwrap()
5624                .unwrap()
5625                .observe()
5626                .unwrap();
5627            let verdict = crate::live_children::identity_verdict(&entry, entry.pid, &observed);
5628            module.drain().await.unwrap();
5629            assert_eq!(
5630                verdict,
5631                crate::live_children::IdentityVerdict::Matches,
5632                "program {program:?}: recorded {entry:?}, observed {observed:?}"
5633            );
5634        }
5635    }
5636
5637    #[cfg(unix)]
5638    fn http_fixture(
5639        dir: &std::path::Path,
5640        url: &str,
5641        threshold: u32,
5642    ) -> crate::daemon_config::ConfiguredModule {
5643        let path = dir.join("subc.jsonc");
5644        std::fs::write(&path, serde_json::json!({"version": 1, "modules": {"http-process": {
5645            "program": "/bin/sleep", "args": ["60"], "protocol": "none",
5646            "env": {"XDG_DATA_HOME":dir,"XDG_RUNTIME_DIR":dir,"XDG_CONFIG_HOME":dir},
5647            "health": {"http":url,"cadence_ms":20,"deadline_ms":200,"failure_threshold":threshold},
5648            "restart": {"backoff_ms":1,"max_backoff_ms":1}, "drain_timeout_ms":10
5649        }}}).to_string()).unwrap();
5650        crate::daemon_config::load(&path)
5651            .unwrap()
5652            .unwrap()
5653            .modules
5654            .pop()
5655            .unwrap()
5656    }
5657
5658    #[cfg(unix)]
5659    async fn wait_http_health(module: &SupervisedModule, status: SupervisorHealthStatus) {
5660        timeout(Duration::from_secs(5), async {
5661            loop {
5662                if module.status().unwrap().health.status == status {
5663                    break;
5664                }
5665                sleep(Duration::from_millis(5)).await;
5666            }
5667        })
5668        .await
5669        .unwrap_or_else(|_| {
5670            panic!(
5671                "expected {status:?}, got {:?}",
5672                module.status().unwrap().health
5673            )
5674        });
5675    }
5676
5677    #[cfg(unix)]
5678    #[tokio::test]
5679    async fn http_health_status_flips_ok_failing_ok() {
5680        use tokio::io::{AsyncReadExt, AsyncWriteExt};
5681        let dir = cortexkit_test_support::ScratchDir::new("http-status-flips");
5682        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5683        let status = Arc::new(std::sync::atomic::AtomicU16::new(200));
5684        let serving_status = status.clone();
5685        let url = format!("http://{}/healthz", listener.local_addr().unwrap());
5686        let server = tokio::spawn(async move {
5687            loop {
5688                let (mut stream, _) = listener.accept().await.unwrap();
5689                let mut request = [0u8; 2048];
5690                let count = stream.read(&mut request).await.unwrap();
5691                assert!(count > 0, "a probe must send an HTTP request");
5692                let code = serving_status.load(std::sync::atomic::Ordering::SeqCst);
5693                let body = if code == 200 {
5694                    "ready"
5695                } else {
5696                    "{\"status\":\"unavailable\",\"error\":\"scratch failure\"}"
5697                };
5698                let response = format!("HTTP/1.1 {code} scratch\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
5699                let _ = stream.write_all(response.as_bytes()).await;
5700            }
5701        });
5702        let configured = http_fixture(&dir, &url, 1000);
5703        let module =
5704            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5705                .supervise_configured_with_health(
5706                    configured.module_spec(),
5707                    true,
5708                    configured.health,
5709                    configured.drain_timeout_ms,
5710                    configured.restart,
5711                )
5712                .unwrap();
5713        wait_http_health(&module, SupervisorHealthStatus::Ok).await;
5714        status.store(503, std::sync::atomic::Ordering::SeqCst);
5715        wait_http_health(&module, SupervisorHealthStatus::Failing).await;
5716        assert!(module
5717            .status()
5718            .unwrap()
5719            .health
5720            .detail
5721            .unwrap()
5722            .contains("scratch failure"));
5723        status.store(200, std::sync::atomic::Ordering::SeqCst);
5724        wait_http_health(&module, SupervisorHealthStatus::Ok).await;
5725        assert_eq!(module.status().unwrap().health.consecutive_failures, 0);
5726        let before = module.status().unwrap();
5727        let (spec, mut health) = module.configuration().unwrap();
5728        health.http = None;
5729        module
5730            .update_configuration(spec.clone(), health.clone(), Some(10))
5731            .await
5732            .unwrap();
5733        wait_http_health(&module, SupervisorHealthStatus::Unknown).await;
5734        health.http = Some(url);
5735        module
5736            .update_configuration(spec, health, Some(10))
5737            .await
5738            .unwrap();
5739        wait_http_health(&module, SupervisorHealthStatus::Ok).await;
5740        assert_eq!(
5741            module.status().unwrap().pid,
5742            before.pid,
5743            "changing a probe must apply live, not restart its process"
5744        );
5745        let (spec, mut health) = module.configuration().unwrap();
5746        health.failure_threshold = 2;
5747        module
5748            .update_configuration(spec, health, Some(10))
5749            .await
5750            .unwrap();
5751        status.store(503, std::sync::atomic::Ordering::SeqCst);
5752        timeout(Duration::from_secs(5), async {
5753            while module.status().unwrap().spawn_generation == before.spawn_generation {
5754                sleep(Duration::from_millis(5)).await;
5755            }
5756        })
5757        .await
5758        .expect("sustained HTTP 503 responses must trigger the health restart policy");
5759        module.drain().await.unwrap();
5760        server.abort();
5761    }
5762
5763    #[cfg(unix)]
5764    #[tokio::test]
5765    async fn http_health_no_listener_restarts_after_consecutive_failures() {
5766        let dir = cortexkit_test_support::ScratchDir::new("http-refused");
5767        let unused = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5768        let url = format!("http://{}/healthz", unused.local_addr().unwrap());
5769        drop(unused);
5770        let configured = http_fixture(&dir, &url, 2);
5771        let module =
5772            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5773                .supervise_configured_with_health(
5774                    configured.module_spec(),
5775                    true,
5776                    configured.health,
5777                    configured.drain_timeout_ms,
5778                    configured.restart,
5779                )
5780                .unwrap();
5781        let before = module.status().unwrap().spawn_generation;
5782        timeout(Duration::from_secs(5), async {
5783            loop {
5784                let status = module.status().unwrap();
5785                if status.spawn_generation > before {
5786                    assert!(status.lifetime_restarts > 0);
5787                    break;
5788                }
5789                sleep(Duration::from_millis(5)).await;
5790            }
5791        })
5792        .await
5793        .expect("sustained HTTP refusal must trigger the health restart policy");
5794        module.drain().await.unwrap();
5795    }
5796
5797    #[cfg(unix)]
5798    #[tokio::test]
5799    async fn http_health_timeout_honours_deadline() {
5800        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5801        let url = format!("http://{}/healthz", listener.local_addr().unwrap());
5802        let server = tokio::spawn(async move {
5803            let _held = listener.accept().await.unwrap();
5804            std::future::pending::<()>().await;
5805        });
5806        let error = timeout(
5807            Duration::from_secs(1),
5808            probe_http_health(&url, Duration::from_millis(10)),
5809        )
5810        .await
5811        .expect("the probe must enforce its own deadline")
5812        .unwrap_err();
5813        server.abort();
5814        assert!(error.to_string().contains("timed out"));
5815    }
5816
5817    #[cfg(unix)]
5818    #[tokio::test]
5819    async fn http_health_localhost_reaches_an_ipv6_only_listener() {
5820        use tokio::io::{AsyncReadExt, AsyncWriteExt};
5821        let listener = tokio::net::TcpListener::bind("[::1]:0").await.unwrap();
5822        let url = format!(
5823            "http://localhost:{}/healthz",
5824            listener.local_addr().unwrap().port()
5825        );
5826        let server = tokio::spawn(async move {
5827            let (mut stream, _) = listener.accept().await.unwrap();
5828            let mut request = [0u8; 2048];
5829            assert!(stream.read(&mut request).await.unwrap() > 0);
5830            stream
5831                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
5832                .await
5833                .unwrap();
5834        });
5835        // The deadline only bounds a hang. A probe that never tried the IPv6
5836        // address would be refused on 127.0.0.1 and fail at once, so a longer
5837        // deadline does not weaken the assertion; one second timed out under a
5838        // loaded parallel test run.
5839        assert_eq!(
5840            probe_http_health(&url, Duration::from_secs(10))
5841                .await
5842                .unwrap()
5843                .status,
5844            HealthStatus::Ok
5845        );
5846        server.await.unwrap();
5847    }
5848
5849    #[cfg(unix)]
5850    #[tokio::test]
5851    async fn http_health_timeout_keeps_partial_status_and_body() {
5852        use tokio::io::{AsyncReadExt, AsyncWriteExt};
5853        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5854        let url = format!("http://{}/healthz", listener.local_addr().unwrap());
5855        let server = tokio::spawn(async move {
5856            let (mut stream, _) = listener.accept().await.unwrap();
5857            let mut request = [0u8; 2048];
5858            assert!(stream.read(&mut request).await.unwrap() > 0);
5859            stream
5860                .write_all(
5861                    b"HTTP/1.1 503 Unavailable\r\nContent-Length: 100\r\n\r\npartial diagnostic",
5862                )
5863                .await
5864                .unwrap();
5865            std::future::pending::<()>().await;
5866        });
5867        let error = probe_http_health(&url, Duration::from_secs(1))
5868            .await
5869            .unwrap_err()
5870            .to_string();
5871        server.abort();
5872        assert!(
5873            error.contains("timed out")
5874                && error.contains("503 Unavailable")
5875                && error.contains("partial diagnostic"),
5876            "{error}"
5877        );
5878    }
5879
5880    #[cfg(unix)]
5881    #[tokio::test]
5882    async fn http_health_diagnostic_body_is_bounded_to_200_bytes() {
5883        use tokio::io::{AsyncReadExt, AsyncWriteExt};
5884        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5885        let url = format!("http://{}/healthz", listener.local_addr().unwrap());
5886        let server = tokio::spawn(async move {
5887            let (mut stream, _) = listener.accept().await.unwrap();
5888            let mut request = [0u8; 2048];
5889            assert!(stream.read(&mut request).await.unwrap() > 0);
5890            let body = format!("{}not-in-diagnostic", "x".repeat(400));
5891            let response = format!(
5892                "HTTP/1.1 503 Unavailable\r\nContent-Length: {}\r\n\r\n{body}",
5893                body.len()
5894            );
5895            stream.write_all(response.as_bytes()).await.unwrap();
5896        });
5897        let error = probe_http_health(&url, Duration::from_secs(1))
5898            .await
5899            .unwrap_err()
5900            .to_string();
5901        server.await.unwrap();
5902        assert!(error.ends_with(&"x".repeat(200)), "{error}");
5903        assert_eq!(error.split(": ").last().unwrap().len(), 200);
5904        assert!(!error.contains("not-in-diagnostic"));
5905    }
5906
5907    #[cfg(unix)]
5908    #[tokio::test]
5909    async fn http_health_real_nats_server_monitoring() {
5910        if std::process::Command::new("nats-server")
5911            .arg("--version")
5912            .env("XDG_DATA_HOME", std::env::temp_dir())
5913            .env("XDG_RUNTIME_DIR", std::env::temp_dir())
5914            .env("XDG_CONFIG_HOME", std::env::temp_dir())
5915            .output()
5916            .is_err()
5917        {
5918            eprintln!(
5919                "skipped: http_health_real_nats_server_monitoring (nats-server is not on PATH)"
5920            );
5921            return;
5922        }
5923        let dir = cortexkit_test_support::ScratchDir::new("nats-http-monitoring");
5924        let monitor = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5925        let port = monitor.local_addr().unwrap().port();
5926        let client = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5927        let client_port = client.local_addr().unwrap().port();
5928        let config = dir.join("server.conf");
5929        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();
5930        let mut configured = http_fixture(&dir, &format!("http://127.0.0.1:{port}/healthz"), 1000);
5931        configured.program = PathBuf::from("nats-server");
5932        configured.args = vec!["-c".into(), config.to_string_lossy().into_owned()];
5933        drop(monitor);
5934        drop(client);
5935        let module =
5936            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5937                .supervise_configured_with_health(
5938                    configured.module_spec(),
5939                    true,
5940                    configured.health,
5941                    configured.drain_timeout_ms,
5942                    configured.restart,
5943                )
5944                .unwrap();
5945        wait_http_health(&module, SupervisorHealthStatus::Ok).await;
5946        module.drain().await.unwrap();
5947    }
5948
5949    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5950    async fn configuration_update_does_not_replace_captured_running_process_facts() {
5951        let supervisor =
5952            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5953        let snapshot = stale_process_snapshot(ModuleState::Running, true);
5954        let initial = ModuleSpec {
5955            module_id: "rescan-preserves-spawn-facts".to_string(),
5956            program: PathBuf::from("/spawned/module"),
5957            args: Vec::new(),
5958            env: Vec::new(),
5959            reserved: false,
5960            reserved_prefixes: Vec::new(),
5961            protocol: ModuleProtocol::Subc,
5962            overlap: Default::default(),
5963        };
5964        let module = supervisor.supervised_module(
5965            initial.clone(),
5966            supervisor.runtime_config(),
5967            snapshot,
5968            None,
5969        );
5970        let before = module.status().unwrap();
5971        let mut replacement = initial;
5972        replacement.program = PathBuf::from("/rescanned/replacement-module");
5973
5974        module
5975            .update_configuration(replacement, HealthConfig::default(), None)
5976            .await
5977            .unwrap();
5978
5979        let after = module.status().unwrap();
5980        assert_eq!(after.pid, before.pid);
5981        assert_eq!(after.spawned_at_ms, before.spawned_at_ms);
5982        assert_eq!(after.spawned_from, before.spawned_from);
5983        drop(module);
5984    }
5985}
5986
5987fn unix_ms_now() -> u64 {
5988    SystemTime::now()
5989        .duration_since(UNIX_EPOCH)
5990        .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
5991        .unwrap_or(0)
5992}
5993
5994async fn supervise_loop(
5995    mut spec: ModuleSpec,
5996    mut runtime: SupervisorRuntimeConfig,
5997    registry: Arc<Registry>,
5998    process_liveness: Arc<SupervisorProcessLiveness>,
5999    snapshot: SharedSnapshot,
6000    mut child: Option<SupervisedChild>,
6001    mut commands: mpsc::Receiver<SupervisorCommand>,
6002) {
6003    let mut health_probe = HealthProbeRuntime::default();
6004    // Registry writes (including embedded callers) notify this module only.
6005    // Subscribe before the first refresh; watch retains changes that arrive
6006    // while commands or probes are running. No polling fallback is needed.
6007    let mut registration_changes = match registry.subscribe_module_changes(&spec.module_id) {
6008        Ok(changes) => Some(changes),
6009        Err(err) => {
6010            // A poisoned registry cannot accept further writes, so it cannot
6011            // recover via a timer. Keep exit and command handling alive.
6012            warn!(module_id = %spec.module_id, error = %err, "health prober could not subscribe to registry");
6013            None
6014        }
6015    };
6016    // All restart backoffs run here, including health and operator requests.
6017    // While one is pending the loop serves commands, so disable or drain can
6018    // cancel the replacement without spawning a process just to stop it.
6019    let mut pending_respawn: Option<PendingRespawn> = None;
6020    // Commands a swap handed back to run next (see `swap::SwapEnd`). Served
6021    // before anything else so a stop that interrupted a swap runs at once.
6022    let mut requeued: VecDeque<SupervisorCommand> = VecDeque::new();
6023    loop {
6024        #[cfg(test)]
6025        {
6026            lock_snapshot(&snapshot).unwrap().actor_select = None;
6027        }
6028        #[cfg(target_os = "macos")]
6029        if let Some(active) = child.as_mut() {
6030            active.confirm_privacy_exec().await;
6031        }
6032        if let Some(scheduled) = runtime
6033            .scheduled_respawn
6034            .lock()
6035            .unwrap_or_else(|p| p.into_inner())
6036            .take()
6037        {
6038            pending_respawn = Some(scheduled);
6039        }
6040        if pending_respawn.is_some() && (child.is_some() || !respawn_still_pending(&snapshot)) {
6041            pending_respawn = None;
6042            cancel_deferred_reload(
6043                &runtime,
6044                &spec.module_id,
6045                "respawn cancelled by a supervisor command",
6046            );
6047        }
6048        if child.is_none() && pending_respawn.is_none() {
6049            cancel_deferred_reload(
6050                &runtime,
6051                &spec.module_id,
6052                "respawn cancelled before a replacement was spawned",
6053            );
6054            let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6055                state.respawn_pending = false;
6056                state.coalesced_restart_pending = false;
6057                if matches!(
6058                    state.state,
6059                    ModuleState::Restarting
6060                        | ModuleState::Starting
6061                        | ModuleState::Draining
6062                        | ModuleState::Unresponsive
6063                ) {
6064                    error!(module_id = %spec.module_id, state = ?state.state,
6065                        "supervision operation ended without a child or pending respawn; marking failed so start can retry");
6066                    state.state = ModuleState::Failed;
6067                    clear_current_process_facts(state);
6068                }
6069            });
6070        }
6071        if let Some(command) = requeued.pop_front() {
6072            if !handle_supervisor_command(
6073                command,
6074                &mut spec,
6075                &mut runtime,
6076                &registry,
6077                &process_liveness,
6078                &snapshot,
6079                &mut child,
6080                &mut commands,
6081                &mut requeued,
6082            )
6083            .await
6084            {
6085                return;
6086            }
6087            if child.is_some() || !respawn_still_pending(&snapshot) {
6088                pending_respawn = None;
6089                let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6090                    state.respawn_pending = false
6091                });
6092            }
6093            continue;
6094        }
6095        if child.is_some() {
6096            #[cfg(test)]
6097            {
6098                lock_snapshot(&snapshot).unwrap().actor_turns += 1;
6099            }
6100            health_probe.refresh_registration(&spec, &runtime, &registry, &snapshot);
6101            let wake_after = health_probe.wake_after();
6102            let probe_sleep = async move {
6103                match wake_after {
6104                    Some(delay) => sleep(delay).await,
6105                    None => std::future::pending().await,
6106                }
6107            };
6108            tokio::pin!(probe_sleep);
6109            // A `protocol: "none"` child (a plain process that never registers
6110            // over the subc wire, such as nats-server) with no HTTP health
6111            // check has nothing to probe, so it waits only for its exit or a
6112            // supervisor command. A rescan that adds an HTTP check is a command.
6113            let wire_child = running_protocol(&spec, &snapshot) == ModuleProtocol::Subc;
6114            #[cfg(test)]
6115            {
6116                let registration_pending = wire_child
6117                    && registration_changes
6118                        .as_ref()
6119                        .is_some_and(|changes| changes.has_changed().unwrap());
6120                let mut state = lock_snapshot(&snapshot).unwrap();
6121                state.actor_select = (!registration_pending).then_some(ActorSelectCheckpoint {
6122                    generation: state.spawn_generation,
6123                    turn: state.actor_turns,
6124                    registered_connection: health_probe.registered_connection,
6125                    next_probe_at: health_probe.next_probe_at,
6126                    wake_after,
6127                });
6128            }
6129            let active_child = child.as_mut().expect("child checked above");
6130            tokio::select! {
6131                wait_result = active_child.wait() => {
6132                    #[cfg(test)]
6133                    { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6134                    // Every arm below that gives up on the CHILD must keep the
6135                    // supervision task itself alive (child = None, loop
6136                    // continues into command-serving mode). Returning here
6137                    // closes the command channel, which makes the module
6138                    // permanently unrestartable in-band: a clean child exit
6139                    // of an enabled module once wedged the fleet this way
6140                    // ('supervisor command channel is closed') and required a
6141                    // full daemon restart to recover.
6142                    let exit_report = match wait_result {
6143                        Ok(status) => classify_reaped_child_exit(&snapshot, active_child, &status),
6144                        Err(err) => {
6145                            active_child.drain_stderr(&spec.module_id).await;
6146                            fail_snapshot(&snapshot, Some(&spec.module_id), None);
6147                            // Every other exit path (on_child_exit's Clean/Crash arms,
6148                            // the reload-registration-failure path) records a terminal
6149                            // before moving on. Without one here, a module whose wait()
6150                            // itself errored (e.g. already reaped) leaves no terminal
6151                            // record at all -- an empty ring reads as "nothing died".
6152                            record_wait_error_terminal(
6153                                &spec.module_id,
6154                                &runtime.terminal_ring,
6155                                &runtime.spawn_events,
6156                            );
6157                            untrack_if_registration_released(
6158                                &process_liveness,
6159                                &registry,
6160                                &spec.module_id,
6161                                &snapshot,
6162                            );
6163                            error!(module_id = %spec.module_id, error = %err, "failed to wait for supervised module");
6164                            child = None;
6165                            continue;
6166                        }
6167                    };
6168                    active_child.drain_stderr(&spec.module_id).await;
6169
6170                    let next = on_child_exit(
6171                        &spec,
6172                        runtime.restart_policy,
6173                        &registry,
6174                        &snapshot,
6175                        &runtime.terminal_ring,
6176                        &runtime.spawn_events,
6177                        &runtime.child_roster,
6178                        exit_report,
6179                    ).await;
6180                    // The exit is recorded, so a daemon shutdown may stop
6181                    // waiting for this child (see `SupervisedChild::wait`).
6182                    active_child.release_roster();
6183                    match next {
6184                        NextAction::Stop { registration_released } => {
6185                            if registration_released {
6186                                process_liveness.untrack_if_current(&spec.module_id, &snapshot);
6187                            }
6188                            child = None;
6189                        }
6190                        NextAction::Restart { schedule } => {
6191                            let delay = schedule.map_or(
6192                                runtime.restart_policy.delay_for_restart(0),
6193                                |schedule| schedule.delay,
6194                            );
6195                            if let Some(schedule) = schedule {
6196                                log_crash_respawn(&spec.module_id, schedule);
6197                            }
6198                            // The exited child is fully recorded at this point,
6199                            // so release it and count the backoff down in the
6200                            // command-serving branch below rather than sleeping
6201                            // here: commands cannot be received from inside this
6202                            // select arm, and an operator disable or drain that
6203                            // arrives during the backoff must cancel the pending
6204                            // respawn instead of waiting for it to spawn first.
6205                            child = None;
6206                            pending_respawn = Some(PendingRespawn { deadline: Instant::now() + delay, kind: RespawnKind::Spawn });
6207                             let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| state.respawn_pending = true);
6208                        }
6209                    }
6210                }
6211                command = commands.recv() => {
6212                    #[cfg(test)]
6213                    { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6214                    let Some(command) = command else {
6215                        return;
6216                    };
6217                    if !handle_supervisor_command(
6218                        command,
6219                        &mut spec,
6220                        &mut runtime,
6221                        &registry,
6222                        &process_liveness,
6223                        &snapshot,
6224                        &mut child,
6225                        &mut commands,
6226                        &mut requeued,
6227                    ).await {
6228                        return;
6229                    }
6230                }
6231                _ = async {
6232                    match registration_changes.as_mut() {
6233                        Some(changes) => { let _ = changes.changed().await; }
6234                        None => std::future::pending().await,
6235                    }
6236                }, if wire_child => {
6237                    #[cfg(test)]
6238                    { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6239                }
6240                _ = &mut probe_sleep => {
6241                    #[cfg(test)]
6242                    { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6243                    if health_probe.due() {
6244                        run_health_probe_cycle(
6245                            &spec,
6246                            &runtime,
6247                            &registry,
6248                            &process_liveness,
6249                            &snapshot,
6250                            &mut child,
6251                        ).await;
6252                        if child.is_some() {
6253                            health_probe.schedule_next(&spec, runtime.health.cadence);
6254                        }
6255                    }
6256                }
6257            }
6258        } else if let Some(pending) = pending_respawn {
6259            tokio::select! {
6260                _ = sleep_until(pending.deadline) => {
6261                    pending_respawn = None;
6262                let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| state.respawn_pending = false);
6263                    // A command handled below while the backoff elapsed may
6264                    // have stopped the module; never respawn past an operator's
6265                    // disable or drain.
6266                    if !respawn_still_pending(&snapshot) {
6267                        continue;
6268                    }
6269                    // The daemon began shutting down during the backoff: the
6270                    // spawn would be refused anyway, and refusing it here
6271                    // leaves the module stopped instead of reporting a
6272                    // failed restart.
6273                    if runtime.child_roster.is_closed() {
6274                        let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6275                            state.state = ModuleState::Stopped;
6276                        });
6277                        debug!(module_id = %spec.module_id, "crash respawn cancelled by daemon shutdown");
6278                        continue;
6279                    }
6280                    if let Err(err) = release_dead_registration(
6281                        &registry,
6282                        runtime.forwarding.as_deref(),
6283                        &snapshot,
6284                        &spec.module_id,
6285                    ).await {
6286                        fail_snapshot(&snapshot, Some(&spec.module_id), None);
6287                        error!(module_id = %spec.module_id, error = %err, "registration did not release before restart");
6288                        continue;
6289                    }
6290
6291                    if matches!(pending.kind, RespawnKind::Reload) {
6292                        let reply = runtime.deferred_reload_reply.lock().unwrap_or_else(|p| p.into_inner()).take();
6293                        let result = finish_reload_child(&spec, &runtime, &registry, &process_liveness, &snapshot, &mut child).await;
6294                        if let Some(reply) = reply { let _ = reply.send(result); }
6295                        continue;
6296                    }
6297                    process_liveness.track(spec.module_id.clone(), Arc::clone(&snapshot));
6298                    match spawn_and_mark_running(&spec, &runtime, &snapshot) {
6299                        Ok(next_child) => {
6300                            child = Some(next_child);
6301                            debug!(module_id = %spec.module_id, "supervised module restarted after crash");
6302                        }
6303                        Err(err) => {
6304                            fail_snapshot(&snapshot, Some(&spec.module_id), None);
6305                            process_liveness.untrack_if_current(&spec.module_id, &snapshot);
6306                            error!(module_id = %spec.module_id, error = %err, "failed to restart supervised module");
6307                        }
6308                    }
6309                }
6310                command = commands.recv() => {
6311                    let Some(command) = command else {
6312                        return;
6313                    };
6314                    if !handle_supervisor_command(
6315                        command,
6316                        &mut spec,
6317                        &mut runtime,
6318                        &registry,
6319                        &process_liveness,
6320                        &snapshot,
6321                        &mut child,
6322                        &mut commands,
6323                        &mut requeued,
6324                    ).await {
6325                        return;
6326                    }
6327                    // Reconcile the pending respawn with what the command did:
6328                    // a start may already have spawned a fresh child,
6329                    // while a disable or drain moved the snapshot out of the
6330                    // state the respawn was counting down from.
6331                    if child.is_some() || !respawn_still_pending(&snapshot) {
6332                        pending_respawn = None;
6333                let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| state.respawn_pending = false);
6334                    }
6335                }
6336            }
6337        } else {
6338            let Some(command) = commands.recv().await else {
6339                return;
6340            };
6341            if !handle_supervisor_command(
6342                command,
6343                &mut spec,
6344                &mut runtime,
6345                &registry,
6346                &process_liveness,
6347                &snapshot,
6348                &mut child,
6349                &mut commands,
6350                &mut requeued,
6351            )
6352            .await
6353            {
6354                return;
6355            }
6356        }
6357    }
6358}
6359
6360fn log_crash_respawn(module_id: &str, schedule: CrashRestartSchedule) {
6361    info!(
6362        module_id,
6363        restart_in_window = schedule.restart_in_window,
6364        delay_ms = schedule.delay.as_millis() as u64,
6365        "respawning after crash"
6366    );
6367}
6368
6369/// Whether the respawn a backoff was counting down to is still wanted. A
6370/// disable or drain handled while the backoff elapsed moves the snapshot out
6371/// of `Restarting`, and the operator's stop must win over the pending respawn,
6372/// so every sleep-then-spawn path re-validates against the live snapshot
6373/// instead of assuming the state it left behind still holds.
6374fn respawn_still_pending(snapshot: &SharedSnapshot) -> bool {
6375    matches!(
6376        lock_snapshot(snapshot),
6377        Ok(state) if state.enabled && state.state == ModuleState::Restarting
6378    )
6379}
6380
6381enum NextAction {
6382    Stop {
6383        registration_released: bool,
6384    },
6385    Restart {
6386        schedule: Option<CrashRestartSchedule>,
6387    },
6388}
6389
6390#[allow(clippy::too_many_arguments)]
6391async fn handle_supervisor_command(
6392    command: SupervisorCommand,
6393    spec: &mut ModuleSpec,
6394    runtime: &mut SupervisorRuntimeConfig,
6395    registry: &Arc<Registry>,
6396    process_liveness: &SupervisorProcessLiveness,
6397    snapshot: &SharedSnapshot,
6398    child: &mut Option<SupervisedChild>,
6399    commands: &mut mpsc::Receiver<SupervisorCommand>,
6400    requeued: &mut VecDeque<SupervisorCommand>,
6401) -> bool {
6402    match command {
6403        SupervisorCommand::Drain { reply } => {
6404            // A plain stop runs no forwarding drain, so nothing reaches the
6405            // module over its connection before the wait: ask by signal.
6406            let result = drain_optional_child(
6407                &spec.module_id,
6408                spec.protocol,
6409                StopNotice::NotSent,
6410                registry,
6411                runtime.forwarding.as_deref(),
6412                snapshot,
6413                &runtime.terminal_ring,
6414                &runtime.spawn_events,
6415                child,
6416                runtime.drain_timeout,
6417                ModuleState::Stopped,
6418                None,
6419            )
6420            .await;
6421            let registration_released = result.is_ok();
6422            let _ = reply.send(result);
6423            if registration_released {
6424                process_liveness.untrack_if_current(&spec.module_id, snapshot);
6425            }
6426            false
6427        }
6428        SupervisorCommand::Retire { reply } => {
6429            let result = async {
6430                let stop_notice = begin_forwarding_drain_if_configured(
6431                    spec,
6432                    runtime,
6433                    registry,
6434                    snapshot,
6435                    None,
6436                    RouteCloseReason::Disable,
6437                )
6438                .await?;
6439                drain_optional_child(
6440                    &spec.module_id,
6441                    spec.protocol,
6442                    stop_notice,
6443                    registry,
6444                    runtime.forwarding.as_deref(),
6445                    snapshot,
6446                    &runtime.terminal_ring,
6447                    &runtime.spawn_events,
6448                    child,
6449                    runtime.drain_timeout,
6450                    ModuleState::Stopped,
6451                    None,
6452                )
6453                .await
6454            }
6455            .await;
6456            let registration_released = result.is_ok();
6457            let _ = reply.send(result);
6458            if registration_released {
6459                process_liveness.untrack_if_current(&spec.module_id, snapshot);
6460            }
6461            false
6462        }
6463        SupervisorCommand::Restart {
6464            drain_timeout_ms,
6465            received_at_generation,
6466            queued_at,
6467            reply,
6468        } => {
6469            // Without this line a restart that waited in the queue (behind a
6470            // health probe cycle or another command) was invisible: the log
6471            // showed only the drain timing out, minutes after the operator's call.
6472            info!(
6473                module_id = %spec.module_id,
6474                queued_ms = u64::try_from(queued_at.elapsed().as_millis()).unwrap_or(u64::MAX),
6475                "restart command dequeued"
6476            );
6477            // ACK AT INITIATION, not completion. The blocking form deadlocked any
6478            // caller whose own request lane rides the module being restarted: the
6479            // caller's in-flight request keeps the drain from quiescing, the drain
6480            // keeps the restart from completing, and the completion keeps the reply
6481            // from releasing the caller — so the drain always timed out and cut the
6482            // initiator with a GOODBYE, even on a healthy module. Replying once the
6483            // restart is validated lets a self-lane caller settle, which is exactly
6484            // what makes the drain succeed. Completion is observable via
6485            // supervisor.list / module status; a post-ack failure lands the module
6486            // in a visible terminal state below rather than in a reply nobody can
6487            // receive.
6488            let validation = match lock_snapshot(snapshot) {
6489                Ok(state) if !state.enabled => Err(SuperviseError::Disabled {
6490                    module_id: spec.module_id.clone(),
6491                }),
6492                Ok(_) => Ok(()),
6493                Err(err) => Err(err),
6494            };
6495            let initiated = validation.is_ok();
6496            let _ = reply.send(validation);
6497            // A restart asks for a fresh process. Commands run one at a time,
6498            // so a restart queued behind another restart (two operator calls
6499            // in quick succession) is dequeued the moment the first one has
6500            // spawned its replacement -- before that process has sent HELLO.
6501            // Running it would drain and kill the process the first restart
6502            // just produced, which is the opposite of what both callers asked
6503            // for. If a process spawned after this request was received is
6504            // still supervised, the request is already satisfied. Not when the
6505            // configuration changed since that spawn: then the newer process
6506            // predates the spec this restart may exist to apply.
6507            let satisfied_by_generation = if initiated && child.is_some() {
6508                lock_snapshot(snapshot).ok().and_then(|state| {
6509                    (state.spawn_generation > received_at_generation
6510                        && !state.configuration_updated_since_spawn)
6511                        .then_some(state.spawn_generation)
6512                })
6513            } else {
6514                None
6515            };
6516            let satisfied_by_pending = initiated
6517                && child.is_none()
6518                && lock_snapshot(snapshot).ok().is_some_and(|mut state| {
6519                    let pending = state.respawn_pending && !state.configuration_updated_since_spawn;
6520                    if pending {
6521                        state.coalesced_restart_pending = true;
6522                    }
6523                    pending
6524                });
6525            if satisfied_by_pending {
6526                debug!(module_id = %spec.module_id, "restart coalesced into the pending replacement");
6527            } else if let Some(generation) = satisfied_by_generation {
6528                info!(
6529                    module_id = %spec.module_id,
6530                    received_at_generation,
6531                    "restart already satisfied by generation {generation}; not restarting again"
6532                );
6533            } else if initiated {
6534                // Precedence: this restart's operator override, else the module's
6535                // configured budget (already resolved into the runtime).
6536                let drain_timeout = drain_timeout_ms
6537                    .map(Duration::from_millis)
6538                    .unwrap_or(runtime.drain_timeout);
6539                if let Err(err) = restart_child(
6540                    spec,
6541                    runtime,
6542                    registry,
6543                    process_liveness,
6544                    snapshot,
6545                    child,
6546                    drain_timeout,
6547                )
6548                .await
6549                {
6550                    warn!(
6551                        module_id = %spec.module_id,
6552                        error = %err,
6553                        "operator restart failed after initiation ack; module state carries the outcome"
6554                    );
6555                    let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
6556                        state.state = ModuleState::Failed;
6557                        clear_current_process_facts(state);
6558                    });
6559                }
6560            }
6561            true
6562        }
6563        SupervisorCommand::Reload { reply } => {
6564            let result =
6565                reload_child(spec, runtime, registry, process_liveness, snapshot, child).await;
6566            if result.is_ok()
6567                && runtime
6568                    .scheduled_respawn
6569                    .lock()
6570                    .unwrap_or_else(|p| p.into_inner())
6571                    .as_ref()
6572                    .is_some_and(|pending| matches!(pending.kind, RespawnKind::Reload))
6573            {
6574                *runtime
6575                    .deferred_reload_reply
6576                    .lock()
6577                    .unwrap_or_else(|p| p.into_inner()) = Some(reply);
6578            } else {
6579                let _ = reply.send(result);
6580            }
6581            true
6582        }
6583        SupervisorCommand::SetEnabled { enabled, reply } => {
6584            let result = set_child_enabled(
6585                spec,
6586                runtime,
6587                registry,
6588                process_liveness,
6589                snapshot,
6590                child,
6591                enabled,
6592            )
6593            .await;
6594            let _ = reply.send(result);
6595            true
6596        }
6597        SupervisorCommand::UpdateConfiguration {
6598            spec: next_spec,
6599            health,
6600            drain_timeout_ms,
6601            reply,
6602        } => {
6603            if let Some(handle) = &runtime.supervisor_handle {
6604                handle.apply_identity_configuration(&next_spec);
6605            }
6606            *spec = next_spec;
6607            let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
6608                state.configuration_updated_since_spawn = true;
6609            });
6610            let health_changed = runtime.health != health;
6611            runtime.health = health;
6612            // Reset the cadence and old endpoint's failure streak on a live
6613            // health-policy change rather than waiting for its old deadline.
6614            if health_changed {
6615                let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
6616                    state.health = ModuleHealthStatus::default();
6617                });
6618            }
6619            runtime.drain_timeout = drain_timeout_ms
6620                .map(Duration::from_millis)
6621                .unwrap_or(runtime.default_drain_timeout);
6622            *runtime
6623                .effective_drain_timeout
6624                .lock()
6625                .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.drain_timeout;
6626            let _ = reply.send(());
6627            true
6628        }
6629        SupervisorCommand::Swap {
6630            ready_timeout,
6631            reply,
6632        } => {
6633            let end = swap::run_swap(
6634                spec,
6635                runtime,
6636                registry,
6637                process_liveness,
6638                snapshot,
6639                child,
6640                commands,
6641                ready_timeout.unwrap_or(DEFAULT_SWAP_READY_TIMEOUT),
6642                reply,
6643            )
6644            .await;
6645            requeued.extend(end.requeue);
6646            true
6647        }
6648    }
6649}
6650
6651async fn restart_child(
6652    spec: &ModuleSpec,
6653    runtime: &SupervisorRuntimeConfig,
6654    registry: &Registry,
6655    process_liveness: &SupervisorProcessLiveness,
6656    snapshot: &SharedSnapshot,
6657    child: &mut Option<SupervisedChild>,
6658    drain_timeout: Duration,
6659) -> Result<(), SuperviseError> {
6660    // Restart cycles a running module; it must not silently start a disabled one.
6661    if !lock_snapshot(snapshot)?.enabled {
6662        return Err(SuperviseError::Disabled {
6663            module_id: spec.module_id.clone(),
6664        });
6665    }
6666    let stop_notice = begin_forwarding_drain_with_timeout(
6667        spec,
6668        runtime,
6669        registry,
6670        snapshot,
6671        None,
6672        RouteCloseReason::Restart,
6673        drain_timeout,
6674    )
6675    .await?;
6676
6677    if child.is_some() {
6678        drain_optional_child(
6679            &spec.module_id,
6680            spec.protocol,
6681            stop_notice,
6682            registry,
6683            runtime.forwarding.as_deref(),
6684            snapshot,
6685            &runtime.terminal_ring,
6686            &runtime.spawn_events,
6687            child,
6688            drain_timeout,
6689            ModuleState::Restarting,
6690            Some(true),
6691        )
6692        .await?;
6693    } else {
6694        update_snapshot(snapshot, Some(&spec.module_id), |state| {
6695            state.enabled = true;
6696            state.state = ModuleState::Restarting;
6697            clear_current_process_facts(state);
6698        })?;
6699        release_dead_registration(
6700            registry,
6701            runtime.forwarding.as_deref(),
6702            snapshot,
6703            &spec.module_id,
6704        )
6705        .await?;
6706    }
6707
6708    reset_restart_count(snapshot, &spec.module_id)?;
6709    process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
6710    schedule_respawn(
6711        runtime,
6712        snapshot,
6713        &spec.module_id,
6714        runtime.restart_policy.backoff,
6715        RespawnKind::Spawn,
6716    )
6717}
6718
6719async fn reload_child(
6720    spec: &ModuleSpec,
6721    runtime: &SupervisorRuntimeConfig,
6722    registry: &Registry,
6723    process_liveness: &SupervisorProcessLiveness,
6724    snapshot: &SharedSnapshot,
6725    child: &mut Option<SupervisedChild>,
6726) -> Result<(), SuperviseError> {
6727    // Reload cycles a running module; it must not silently start a disabled one.
6728    if !lock_snapshot(snapshot)?.enabled {
6729        return Err(SuperviseError::Disabled {
6730            module_id: spec.module_id.clone(),
6731        });
6732    }
6733    let stop_notice = begin_forwarding_drain(
6734        spec,
6735        runtime,
6736        registry,
6737        snapshot,
6738        Some(true),
6739        RouteCloseReason::Reload,
6740    )
6741    .await?;
6742
6743    if child.is_some() {
6744        drain_optional_child(
6745            &spec.module_id,
6746            spec.protocol,
6747            stop_notice,
6748            registry,
6749            runtime.forwarding.as_deref(),
6750            snapshot,
6751            &runtime.terminal_ring,
6752            &runtime.spawn_events,
6753            child,
6754            runtime.drain_timeout,
6755            ModuleState::Restarting,
6756            Some(true),
6757        )
6758        .await?;
6759    } else {
6760        update_snapshot(snapshot, Some(&spec.module_id), |state| {
6761            state.enabled = true;
6762            state.state = ModuleState::Restarting;
6763            clear_current_process_facts(state);
6764        })?;
6765        release_dead_registration(
6766            registry,
6767            runtime.forwarding.as_deref(),
6768            snapshot,
6769            &spec.module_id,
6770        )
6771        .await?;
6772    }
6773
6774    reset_restart_count(snapshot, &spec.module_id)?;
6775    process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
6776    schedule_respawn(
6777        runtime,
6778        snapshot,
6779        &spec.module_id,
6780        runtime.restart_policy.backoff,
6781        RespawnKind::Reload,
6782    )
6783}
6784
6785async fn finish_reload_child(
6786    spec: &ModuleSpec,
6787    runtime: &SupervisorRuntimeConfig,
6788    registry: &Registry,
6789    process_liveness: &SupervisorProcessLiveness,
6790    snapshot: &SharedSnapshot,
6791    child: &mut Option<SupervisedChild>,
6792) -> Result<(), SuperviseError> {
6793    process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
6794    let next_child = match spawn_and_mark_running(spec, runtime, snapshot) {
6795        Ok(next_child) => next_child,
6796        Err(err) => {
6797            return handle_reload_spawn_failure(
6798                spec,
6799                runtime,
6800                process_liveness,
6801                snapshot,
6802                child,
6803                format!("new child failed to spawn: {err}"),
6804            )
6805            .await;
6806        }
6807    };
6808    *child = Some(next_child);
6809
6810    let wait_outcome = {
6811        let active_child = child.as_mut().expect("new reload child was just stored");
6812        wait_for_registration_after_reload(
6813            registry,
6814            &spec.module_id,
6815            snapshot,
6816            active_child,
6817            REGISTRY_RELEASE_TIMEOUT,
6818        )
6819        .await?
6820    };
6821
6822    match wait_outcome {
6823        RegistrationWaitOutcome::Registered => {
6824            debug!(module_id = %spec.module_id, "supervised module reloaded and registered");
6825            Ok(())
6826        }
6827        RegistrationWaitOutcome::Exited(exit_report) => {
6828            if let Some(active_child) = child.as_mut() {
6829                active_child.drain_stderr(&spec.module_id).await;
6830            }
6831            // Keep the reaped child's roster guard until its terminal is written.
6832            // Shutdown waits on that guard, not on the child Option used for respawn.
6833            let mut exited_child = child.take().expect("exited reload child is still stored");
6834            #[cfg(test)]
6835            if let Some(gate) = &runtime.test_reload_exit_record_gate {
6836                gate.reached.notify_one();
6837                gate.resume.notified().await;
6838            }
6839            let result = handle_reload_child_registration_failure(
6840                spec,
6841                runtime,
6842                registry,
6843                process_liveness,
6844                snapshot,
6845                child,
6846                ReloadRegistrationFailure {
6847                    exit_report: registration_failure_exit_report(exit_report),
6848                    reason: exited_child
6849                        .spawn_failure
6850                        .clone()
6851                        .unwrap_or_else(|| "new child exited before registering".to_string()),
6852                },
6853            )
6854            .await;
6855            exited_child.release_roster();
6856            result
6857        }
6858        RegistrationWaitOutcome::TimedOut => {
6859            let mut timed_out_child = child
6860                .take()
6861                .expect("timed-out reload child is still running");
6862            timed_out_child
6863                .start_kill()
6864                .map_err(|source| SuperviseError::Kill {
6865                    module_id: spec.module_id.clone(),
6866                    source,
6867                })?;
6868            let status = timed_out_child
6869                .wait()
6870                .await
6871                .map_err(|source| SuperviseError::Wait {
6872                    module_id: spec.module_id.clone(),
6873                    source,
6874                })?;
6875            timed_out_child.drain_stderr(&spec.module_id).await;
6876            handle_reload_child_registration_failure(
6877                spec,
6878                runtime,
6879                registry,
6880                process_liveness,
6881                snapshot,
6882                child,
6883                ReloadRegistrationFailure {
6884                    exit_report: registration_failure_exit_report(classify_reaped_child_exit(
6885                        snapshot,
6886                        &timed_out_child,
6887                        &status,
6888                    )),
6889                    reason: format!(
6890                        "new child did not register within {:?}",
6891                        REGISTRY_RELEASE_TIMEOUT
6892                    ),
6893                },
6894            )
6895            .await
6896        }
6897    }
6898}
6899
6900async fn set_child_enabled(
6901    spec: &ModuleSpec,
6902    runtime: &SupervisorRuntimeConfig,
6903    registry: &Registry,
6904    process_liveness: &SupervisorProcessLiveness,
6905    snapshot: &SharedSnapshot,
6906    child: &mut Option<SupervisedChild>,
6907    enabled: bool,
6908) -> Result<bool, SuperviseError> {
6909    let (current_enabled, current_state, respawn_pending) = {
6910        let state = lock_snapshot(snapshot)?;
6911        (state.enabled, state.state, state.respawn_pending)
6912    };
6913    // `start` (enable on an already-enabled module) heals TERMINAL states instead
6914    // of no-op'ing: a module whose restart budget exhausted (Failed) or that exited
6915    // clean (Stopped) has no live process and no other in-band recovery — the
6916    // operator's start is the explicit recovery act and resets the budget. Without
6917    // this arm the only revival was subc-probe --supervisor-restart in a terminal,
6918    // which the 2026-07-14 aft outage proved is a trap when the failed module is
6919    // the one providing every agent's shell.
6920    let revive_terminal = enabled
6921        && current_enabled
6922        && child.is_none()
6923        && (matches!(current_state, ModuleState::Failed | ModuleState::Stopped)
6924            || (current_state == ModuleState::Restarting && !respawn_pending));
6925    if current_enabled == enabled && !revive_terminal {
6926        return Ok(false);
6927    }
6928
6929    if enabled {
6930        update_snapshot(snapshot, Some(&spec.module_id), |state| {
6931            state.enabled = true;
6932            state.state = ModuleState::Starting;
6933            clear_current_process_facts(state);
6934        })?;
6935        #[cfg(test)]
6936        if runtime.test_seed_stale_facts_before_enable_spawn {
6937            update_snapshot(snapshot, Some(&spec.module_id), |state| {
6938                state.process_alive = true;
6939                state.pid = Some(41);
6940                state.spawned_at_ms = Some(42);
6941                state.spawned_from = Some(PathBuf::from("/spawned/module"));
6942                state.spawned_file_identity = Some(SpawnedFileIdentity {
6943                    device: 43,
6944                    inode: 44,
6945                });
6946            })?;
6947        }
6948        release_dead_registration(
6949            registry,
6950            runtime.forwarding.as_deref(),
6951            snapshot,
6952            &spec.module_id,
6953        )
6954        .await?;
6955        reset_restart_count(snapshot, &spec.module_id)?;
6956        process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
6957        let next_child = match spawn_and_mark_running(spec, runtime, snapshot) {
6958            Ok(next_child) => next_child,
6959            Err(err) => {
6960                if let Err(state_err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
6961                    state.state = ModuleState::Failed;
6962                    clear_current_process_facts(state);
6963                }) {
6964                    error!(module_id = %spec.module_id, error = %state_err, "failed to record enable spawn failure");
6965                }
6966                process_liveness.untrack_if_current(&spec.module_id, snapshot);
6967                return Err(err);
6968            }
6969        };
6970        *child = Some(next_child);
6971        debug!(module_id = %spec.module_id, "supervised module enabled");
6972        Ok(true)
6973    } else {
6974        let stop_notice = begin_forwarding_drain_if_configured(
6975            spec,
6976            runtime,
6977            registry,
6978            snapshot,
6979            Some(false),
6980            RouteCloseReason::Disable,
6981        )
6982        .await?;
6983        drain_optional_child(
6984            &spec.module_id,
6985            spec.protocol,
6986            stop_notice,
6987            registry,
6988            runtime.forwarding.as_deref(),
6989            snapshot,
6990            &runtime.terminal_ring,
6991            &runtime.spawn_events,
6992            child,
6993            runtime.drain_timeout,
6994            ModuleState::Disabled,
6995            Some(false),
6996        )
6997        .await?;
6998        debug!(module_id = %spec.module_id, "supervised module disabled");
6999        Ok(true)
7000    }
7001}
7002
7003#[allow(clippy::too_many_arguments)]
7004async fn on_child_exit(
7005    spec: &ModuleSpec,
7006    policy: RestartPolicy,
7007    registry: &Registry,
7008    snapshot: &SharedSnapshot,
7009    terminal_ring: &Arc<Mutex<TerminalRing>>,
7010    spawn_events: &SpawnEventFeed,
7011    roster: &ChildRoster,
7012    exit_report: ExitReport,
7013) -> NextAction {
7014    // Once the daemon has begun shutting down, no exit is a crash to recover
7015    // from: the module is exiting because the daemon is going away (EOF on its
7016    // connection, or a service manager signalling the whole cgroup). Record it
7017    // as such and never schedule a respawn, which would only start a process
7018    // for the shutdown to end again.
7019    if roster.is_closed() {
7020        return on_child_exit_during_daemon_shutdown(
7021            spec,
7022            registry,
7023            snapshot,
7024            terminal_ring,
7025            spawn_events,
7026            exit_report,
7027        )
7028        .await;
7029    }
7030    // Every stop the supervisor itself asks for (operator stop, disable,
7031    // restart, reload, swap, a health restart, a drain that runs out of budget)
7032    // takes the child out of the supervise loop and reaps it in
7033    // `drain_child_to_state`, and daemon shutdown is handled above. So an exit
7034    // that reaches this point was not requested by the daemon.
7035    //
7036    // For a subc-wire module a clean exit is still a stop: those modules are
7037    // written to re-raise SIGTERM, so a stray outside signal already reads as a
7038    // crash, and exiting 0 is a deliberate choice the module made. A
7039    // `protocol: "none"` module is a stock program we cannot change, and many
7040    // of them (nats-server among them) exit 0 on SIGTERM. Treating that as a
7041    // stop would leave the module down for good after any stray signal, so it
7042    // goes through the crash path instead: it spends restart budget, respawns
7043    // with the crash backoff, and ends `failed` when the budget runs out.
7044    let unrequested_clean_exit_of_protocol_none = exit_report.kind == ExitKind::Clean
7045        && running_protocol(spec, snapshot) == ModuleProtocol::None;
7046    match exit_report.kind {
7047        ExitKind::Clean if !unrequested_clean_exit_of_protocol_none => {
7048            info!(
7049                module_id = %spec.module_id,
7050                exit_code = ?exit_report.code,
7051                exit_signal = ?exit_report.signal,
7052                "supervised module exited cleanly"
7053            );
7054            if let Err(err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
7055                state.state = ModuleState::Stopped;
7056                clear_current_process_facts(state);
7057                state.last_exit = Some(exit_report.clone());
7058            }) {
7059                error!(module_id = %spec.module_id, error = %err, "failed to record clean module exit");
7060            }
7061            record_terminal(
7062                &spec.module_id,
7063                terminal_ring,
7064                spawn_events,
7065                &exit_report,
7066                TerminalDisposition::Stopped,
7067            );
7068            let registration_released = match wait_for_registration_release(
7069                registry,
7070                &spec.module_id,
7071                REGISTRY_RELEASE_TIMEOUT,
7072            )
7073            .await
7074            {
7075                Ok(()) => true,
7076                Err(err) => {
7077                    warn!(module_id = %spec.module_id, error = %err, "registration still active after clean exit");
7078                    false
7079                }
7080            };
7081            NextAction::Stop {
7082                registration_released,
7083            }
7084        }
7085        ExitKind::Clean | ExitKind::Crash => {
7086            if unrequested_clean_exit_of_protocol_none {
7087                warn!(
7088                    module_id = %spec.module_id,
7089                    exit_code = ?exit_report.code,
7090                    exit_signal = ?exit_report.signal,
7091                    "protocol-none module exited cleanly without a stop request; handling it as a crash"
7092                );
7093            } else {
7094                warn!(
7095                    module_id = %spec.module_id,
7096                    exit_code = ?exit_report.code,
7097                    exit_signal = ?exit_report.signal,
7098                    "supervised module exited abnormally (crash)"
7099                );
7100            }
7101            let mut restart_schedule = None;
7102            let mut disposition = TerminalDisposition::Disabled;
7103            // Set only when the budget is what stopped the module, so the
7104            // terminal record says which limit was hit rather than leaving
7105            // `failed` to be read as "crashed once, badly".
7106            let mut disposition_detail = lock_snapshot(snapshot)
7107                .ok()
7108                .and_then(|mut state| state.spawn_failure.take());
7109            let now = Instant::now();
7110            if let Err(err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
7111                clear_current_process_facts(state);
7112                state.last_exit = Some(exit_report.clone());
7113                if state.enabled {
7114                    if let Some(schedule) = state.next_crash_restart(&policy, now) {
7115                        state.state = ModuleState::Restarting;
7116                        restart_schedule = Some(schedule);
7117                        disposition = TerminalDisposition::Restarting;
7118                    } else {
7119                        disposition = TerminalDisposition::Failed;
7120                        let budget = policy.budget_exhausted_detail();
7121                        disposition_detail =
7122                            Some(disposition_detail.take().map_or_else(
7123                                || budget.clone(),
7124                                |cause| format!("{cause}; {budget}"),
7125                            ));
7126                    }
7127                } else {
7128                    state.state = ModuleState::Disabled;
7129                    disposition = TerminalDisposition::Disabled;
7130                }
7131            }) {
7132                error!(module_id = %spec.module_id, error = %err, "failed to record crashed module exit");
7133                return NextAction::Stop {
7134                    registration_released: false,
7135                };
7136            }
7137            if disposition == TerminalDisposition::Failed {
7138                // The window is in the message, not only in the fields: this line
7139                // is read in a scrollback where a bare `max_restarts=3` reads as a
7140                // lifetime cap and sends the operator looking for three crashes
7141                // that never happened together.
7142                error!(
7143                    module_id = %spec.module_id,
7144                    max_restarts = policy.max_restarts,
7145                    window_secs = policy.window.as_secs(),
7146                    "module stopped: {}",
7147                    policy.budget_exhausted_detail()
7148                );
7149            }
7150            let budget_exhausted = disposition == TerminalDisposition::Failed;
7151            let record_exit = || {
7152                record_terminal_with_detail(
7153                    &spec.module_id,
7154                    terminal_ring,
7155                    spawn_events,
7156                    &exit_report,
7157                    disposition,
7158                    disposition_detail,
7159                );
7160            };
7161            if budget_exhausted {
7162                // Publish Failed only after its terminal record is available.
7163                // Recording takes the event-feed lock, then ring -> journal
7164                // writer (with file I/O), all without the hot snapshot lock.
7165                // No lock is held when the final snapshot update runs, nor
7166                // across the registration-release await below. Commands and
7167                // health actions run on this same supervisor task, so none can
7168                // act on the old state during the write; process facts already
7169                // say the child is dead to concurrent liveness readers.
7170                // A journal error is retained in history, not returned. If
7171                // recording panics, still publish Failed before resuming the
7172                // original unwind rather than leaving a dead child Running.
7173                let recorded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(record_exit));
7174                if let Err(err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
7175                    state.state = ModuleState::Failed;
7176                }) {
7177                    error!(module_id = %spec.module_id, error = %err, "failed to publish exhausted restart budget");
7178                }
7179                if let Err(panic) = recorded {
7180                    std::panic::resume_unwind(panic);
7181                }
7182            } else {
7183                record_exit();
7184            }
7185
7186            if let Some(schedule) = restart_schedule {
7187                NextAction::Restart {
7188                    schedule: Some(schedule),
7189                }
7190            } else {
7191                let registration_released = match wait_for_registration_release(
7192                    registry,
7193                    &spec.module_id,
7194                    REGISTRY_RELEASE_TIMEOUT,
7195                )
7196                .await
7197                {
7198                    Ok(()) => true,
7199                    Err(err) => {
7200                        warn!(module_id = %spec.module_id, error = %err, "registration still active after failed module");
7201                        false
7202                    }
7203                };
7204                NextAction::Stop {
7205                    registration_released,
7206                }
7207            }
7208        }
7209        ExitKind::DeliberateSeverance => {
7210            warn!(
7211                module_id = %spec.module_id,
7212                exit_code = ?exit_report.code,
7213                exit_signal = ?exit_report.signal,
7214                "supervised module exited after deliberate connection severance"
7215            );
7216            let mut should_restart = false;
7217            let mut disposition = TerminalDisposition::Disabled;
7218            if let Err(err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
7219                clear_current_process_facts(state);
7220                state.last_exit = Some(exit_report.clone());
7221                state.lifetime_restarts += 1;
7222                if state.enabled {
7223                    state.state = ModuleState::Restarting;
7224                    should_restart = true;
7225                    disposition = TerminalDisposition::Restarting;
7226                } else {
7227                    state.state = ModuleState::Disabled;
7228                }
7229            }) {
7230                error!(module_id = %spec.module_id, error = %err, "failed to record deliberately severed module exit");
7231                return NextAction::Stop {
7232                    registration_released: false,
7233                };
7234            }
7235            record_terminal(
7236                &spec.module_id,
7237                terminal_ring,
7238                spawn_events,
7239                &exit_report,
7240                disposition,
7241            );
7242
7243            if should_restart {
7244                NextAction::Restart { schedule: None }
7245            } else {
7246                let registration_released = match wait_for_registration_release(
7247                    registry,
7248                    &spec.module_id,
7249                    REGISTRY_RELEASE_TIMEOUT,
7250                )
7251                .await
7252                {
7253                    Ok(()) => true,
7254                    Err(err) => {
7255                        warn!(module_id = %spec.module_id, error = %err, "registration still active after deliberately severed module exit");
7256                        false
7257                    }
7258                };
7259                NextAction::Stop {
7260                    registration_released,
7261                }
7262            }
7263        }
7264    }
7265}
7266
7267async fn on_child_exit_during_daemon_shutdown(
7268    spec: &ModuleSpec,
7269    registry: &Registry,
7270    snapshot: &SharedSnapshot,
7271    terminal_ring: &Arc<Mutex<TerminalRing>>,
7272    spawn_events: &SpawnEventFeed,
7273    exit_report: ExitReport,
7274) -> NextAction {
7275    info!(
7276        module_id = %spec.module_id,
7277        exit_code = ?exit_report.code,
7278        exit_signal = ?exit_report.signal,
7279        exit_kind = ?exit_report.kind,
7280        "supervised module exited during daemon shutdown; not restarting it"
7281    );
7282    if let Err(err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
7283        state.state = ModuleState::Stopped;
7284        clear_current_process_facts(state);
7285        state.last_exit = Some(exit_report.clone());
7286    }) {
7287        error!(module_id = %spec.module_id, error = %err, "failed to record module exit during daemon shutdown");
7288    }
7289    record_terminal(
7290        &spec.module_id,
7291        terminal_ring,
7292        spawn_events,
7293        &exit_report,
7294        TerminalDisposition::DaemonShutdown,
7295    );
7296    let registration_released =
7297        wait_for_registration_release(registry, &spec.module_id, REGISTRY_RELEASE_TIMEOUT)
7298            .await
7299            .is_ok();
7300    NextAction::Stop {
7301        registration_released,
7302    }
7303}
7304
7305fn record_wait_error_terminal(
7306    module_id: &str,
7307    terminal_ring: &Arc<Mutex<TerminalRing>>,
7308    spawn_events: &SpawnEventFeed,
7309) {
7310    record_terminal(
7311        module_id,
7312        terminal_ring,
7313        spawn_events,
7314        &wait_error_exit_report(),
7315        TerminalDisposition::Failed,
7316    );
7317}
7318
7319fn record_terminal(
7320    module_id: &str,
7321    terminal_ring: &Arc<Mutex<TerminalRing>>,
7322    spawn_events: &SpawnEventFeed,
7323    exit_report: &ExitReport,
7324    disposition: TerminalDisposition,
7325) {
7326    record_terminal_with_detail(
7327        module_id,
7328        terminal_ring,
7329        spawn_events,
7330        exit_report,
7331        disposition,
7332        None,
7333    );
7334}
7335
7336/// The ring lock is held only to capture the read (see
7337/// `TerminalJournal::capture_read`), so this module's exits keep recording
7338/// while the journal files are read. Blocking: it reads files.
7339fn durable_terminal_history_of(
7340    terminal_ring: &Mutex<TerminalRing>,
7341    module_id: &str,
7342) -> subc_control::TerminalHistory {
7343    let read = terminal_ring
7344        .lock()
7345        .unwrap_or_else(|p| p.into_inner())
7346        .capture_durable_history();
7347    read.read(module_id)
7348}
7349
7350fn record_terminal_with_detail(
7351    module_id: &str,
7352    terminal_ring: &Arc<Mutex<TerminalRing>>,
7353    spawn_events: &SpawnEventFeed,
7354    exit_report: &ExitReport,
7355    disposition: TerminalDisposition,
7356    disposition_detail: Option<String>,
7357) {
7358    spawn_events.emit_exited(module_id, exit_report.code, exit_report.signal);
7359    let record = TerminalRecord {
7360        exit_code: exit_report.code,
7361        exit_signal: exit_report.signal,
7362        at_ms: exit_report.at_ms,
7363        disposition,
7364        exit_kind: exit_report.kind.into(),
7365        disposition_detail,
7366    };
7367    terminal_ring
7368        .lock()
7369        .unwrap_or_else(|poisoned| poisoned.into_inner())
7370        .record_exit(module_id, record);
7371}
7372
7373fn untrack_if_registration_released(
7374    process_liveness: &SupervisorProcessLiveness,
7375    registry: &Registry,
7376    module_id: &str,
7377    snapshot: &SharedSnapshot,
7378) {
7379    match registry.get_module(module_id) {
7380        Ok(None) => process_liveness.untrack_if_current(module_id, snapshot),
7381        Ok(Some(_)) => {}
7382        Err(err) => {
7383            warn!(module_id, error = %err, "could not determine whether supervisor liveness can be untracked");
7384        }
7385    }
7386}
7387
7388/// The child's environment plan: inherit the parent's, drop ambient `CK_LOG`,
7389/// then apply the module's configured entries minus daemon-private capture keys.
7390///
7391/// Separated from `spawn_child` only so it can be asserted without spawning a
7392/// process — a duplicate of this logic in a test would pass while the real one
7393/// drifted, which is the defect class this function exists to avoid.
7394/// The subc-wire half of a spawn: `--subc <connection file>` and the launch
7395/// nonce. A `protocol: "none"` module gets neither, because it cannot use
7396/// either and the argument would stop a stock binary from starting at all.
7397/// `SUBC_MODULE_ID` is set on every path since an unread variable is inert.
7398///
7399/// The plain-spawn form, kept for the tests that assert its plan; spawns go
7400/// through [`apply_wire_spawn_args_for_role`].
7401#[cfg(test)]
7402fn apply_wire_spawn_args(
7403    command: &mut Command,
7404    spec: &ModuleSpec,
7405    connection_file_path: Option<&std::path::Path>,
7406    handle: Option<&SupervisorHandle>,
7407) -> Result<Option<NonceHandoff>, SuperviseError> {
7408    apply_wire_spawn_args_for_role(
7409        command,
7410        spec,
7411        connection_file_path,
7412        handle,
7413        SpawnRole::Plain,
7414    )
7415}
7416
7417/// The read end of a spawn's launch-nonce pipe, prepared by
7418/// [`apply_wire_spawn_args_for_role`] and installed as the child's descriptor 3
7419/// by the last pre-exec step, just before `spawn()`. Windows has no descriptor
7420/// handoff and keeps only the environment copy.
7421#[cfg(unix)]
7422type NonceHandoff = subc_os::LaunchNonceHandoff;
7423#[cfg(not(unix))]
7424type NonceHandoff = std::convert::Infallible;
7425
7426/// Prepare wire identity for a plain spawn or a swap candidate.
7427///
7428/// A plain spawn replaces the module's recorded nonce. A swap candidate records
7429/// a separate candidate token so the still-serving incumbent and its consumers
7430/// keep their nonce. Both records are installed before the process exists, so
7431/// the child's initial HELLO registration cannot arrive ahead of its nonce.
7432///
7433/// On Unix the nonce is delivered only through a pipe. It is written into
7434/// a pipe whose read end the child gets as descriptor 3, named by
7435/// `SUBC_LAUNCH_NONCE_FD=3:<pipe inode>`: unlike the environment, another
7436/// process of the same user cannot read it with `ps eww`. That handoff is
7437/// returned rather than installed here, because installing it replaces
7438/// whatever the child has at descriptor 3 and so must be the last pre-exec
7439/// step, after the Linux cgroup placement that the caller registers later.
7440/// Windows retains the environment handoff until restricted handle inheritance
7441/// can be implemented outside std's process primitives.
7442fn apply_wire_spawn_args_for_role(
7443    command: &mut Command,
7444    spec: &ModuleSpec,
7445    connection_file_path: Option<&std::path::Path>,
7446    handle: Option<&SupervisorHandle>,
7447    role: SpawnRole,
7448) -> Result<Option<NonceHandoff>, SuperviseError> {
7449    command.env(SUBC_MODULE_ID_ENV, &spec.module_id);
7450    // SUBC_LAUNCH_NONCE_FD is removed for every child, `protocol: "none"`
7451    // included: a daemon started from a module's process tree inherits it,
7452    // and passing it on would point the child at a descriptor it does not
7453    // have.
7454    command.env_remove(subc_os::LAUNCH_NONCE_FD_ENV);
7455    // Remove inherited or configured copies too: withholding must mean absent.
7456    command.env_remove(SUBC_LAUNCH_NONCE_ENV);
7457    if spec.protocol == ModuleProtocol::None {
7458        return Ok(None);
7459    }
7460    if let Some(connection_file_path) = connection_file_path {
7461        command.arg(SUBC_ARG).arg(connection_file_path);
7462    }
7463
7464    // Every subc-wire spawn receives a fresh one-time launch nonce for consumer
7465    // route.open attestation. Reserved modules additionally use the same nonce
7466    // for HELLO id-squatting protection. A respawn rotates both records.
7467    let nonce = generate_launch_nonce()?;
7468    if let Some(handle) = handle {
7469        match role {
7470            SpawnRole::Plain => {
7471                handle.set_spawn_nonce(&spec.module_id, nonce.clone());
7472                if spec.reserved {
7473                    handle.set_reserved_nonce(&spec.module_id, nonce.clone());
7474                }
7475            }
7476            SpawnRole::SwapCandidate => handle.open_swap(&spec.module_id, nonce.clone()),
7477        }
7478    }
7479    #[cfg(unix)]
7480    let handoff = {
7481        let handoff =
7482            subc_os::LaunchNonceHandoff::new(&nonce).map_err(|source| SuperviseError::Spawn {
7483                program: spec.program.clone(),
7484                source,
7485                cgroup_path: None,
7486            })?;
7487        command.env(subc_os::LAUNCH_NONCE_FD_ENV, handoff.fd_env_value());
7488        Some(handoff)
7489    };
7490    #[cfg(not(unix))]
7491    let handoff = None;
7492    // Windows keeps the environment copy: std cannot restrict an inherited pipe
7493    // handle to this child without leaking it to concurrently spawned processes.
7494    #[cfg(not(unix))]
7495    command.env(SUBC_LAUNCH_NONCE_ENV, nonce);
7496    Ok(handoff)
7497}
7498
7499fn apply_child_env(command: &mut Command, spec: &ModuleSpec) {
7500    command.env_remove(CK_LOG_ENV);
7501    // The spawn role is the supervisor's to set, and only on a swap candidate
7502    // (see `apply_spawn_role`). Removing it here, rather than just not setting
7503    // it, is what makes it absent on a plain spawn: the daemon's own
7504    // environment could carry it, and so could a spec built outside daemon
7505    // config (config refuses it as an `env` key). A module reading it on a
7506    // plain restart would pick the long swap budget and leave callers waiting.
7507    command.env_remove(SUBC_SPAWN_ROLE_ENV);
7508    for (key, value) in &spec.env {
7509        // cortexkit-log currently exposes retention only as a Rust struct, not
7510        // environment names. These values are daemon-private sink metadata and
7511        // must never become a public child-process contract by being inherited.
7512        if matches!(
7513            key.as_str(),
7514            CAPTURE_MAX_FILE_MB_ENV | CAPTURE_KEEP_ENV | CAPTURE_MAX_AGE_DAYS_ENV
7515        ) || key == SUBC_SPAWN_ROLE_ENV
7516        {
7517            continue;
7518        }
7519        command.env(key, value);
7520    }
7521}
7522
7523/// Which slot a spawn fills: the module's ordinary one, or the candidate slot
7524/// of a blue/green swap.
7525#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7526enum SpawnRole {
7527    Plain,
7528    SwapCandidate,
7529}
7530
7531/// Set the spawn role for a swap candidate. A plain spawn gets nothing here;
7532/// `apply_child_env` has already removed the variable for every spawn.
7533fn apply_spawn_role(command: &mut Command, role: SpawnRole) {
7534    if role == SpawnRole::SwapCandidate {
7535        command.env(SUBC_SPAWN_ROLE_ENV, SPAWN_ROLE_SWAP_CANDIDATE);
7536    }
7537}
7538
7539fn spawn_child(
7540    spec: &ModuleSpec,
7541    connection_file_path: Option<&std::path::Path>,
7542    handle: Option<&SupervisorHandle>,
7543    ring: &Arc<Mutex<StderrRing>>,
7544    capture_logs_dir: Option<&std::path::Path>,
7545    roster: &ChildRoster,
7546    #[cfg(target_os = "linux")] cgroup_placement: Option<&subc_cgroup::Placement>,
7547) -> Result<SupervisedChild, SuperviseError> {
7548    spawn_child_in_slot(
7549        spec,
7550        connection_file_path,
7551        handle,
7552        ring,
7553        capture_logs_dir,
7554        roster,
7555        #[cfg(target_os = "linux")]
7556        cgroup_placement,
7557        SpawnRole::Plain,
7558        false,
7559    )
7560}
7561
7562/// Spawn one process of `spec` into a slot.
7563///
7564/// `alternate_slot` picks the process's cgroup label on Linux (see `swap::cgroup_name`).
7565/// A swap candidate needs a different cgroup from the process it is replacing,
7566/// which is still alive: in the same cgroup the two would be one kill domain,
7567/// and killing a failed candidate could take the incumbent with it.
7568///
7569/// The stderr capture file is `<module_id>.stderr.log` for every process of
7570/// the module, whichever slot it is in, because that is the one file
7571/// `ck module logs` reads. During a swap's overlap both processes append to it;
7572/// the daemon writes whole lines, so the two interleave by line, which is also
7573/// the merged view an operator wants while a swap runs.
7574#[allow(clippy::too_many_arguments)]
7575fn spawn_child_in_slot(
7576    spec: &ModuleSpec,
7577    connection_file_path: Option<&std::path::Path>,
7578    handle: Option<&SupervisorHandle>,
7579    ring: &Arc<Mutex<StderrRing>>,
7580    capture_logs_dir: Option<&std::path::Path>,
7581    roster: &ChildRoster,
7582    #[cfg(target_os = "linux")] cgroup_placement: Option<&subc_cgroup::Placement>,
7583    role: SpawnRole,
7584    alternate_slot: bool,
7585) -> Result<SupervisedChild, SuperviseError> {
7586    if roster.is_closed() {
7587        return Err(SuperviseError::Spawn {
7588            program: spec.program.clone(),
7589            source: io::Error::other("the daemon is shutting down; not starting a new process"),
7590            cgroup_path: None,
7591        });
7592    }
7593    #[cfg(target_os = "linux")]
7594    let cgroup_name = {
7595        // Slot names alone are not kill domains: a retired incumbent may still
7596        // be draining when a later enable/restart spawns into the same slot.
7597        // Decimal entropy keeps the suffix unambiguous; Placement performs
7598        // the module-id escaping and constructs the filesystem path.
7599        if cgroup_placement.is_none() {
7600            swap::cgroup_name(&spec.module_id, alternate_slot)
7601        } else {
7602            let nonce = generate_launch_nonce()?;
7603            let suffix = u128::from_str_radix(&nonce[..32], 16).expect("hex launch nonce");
7604            // Leave room for byte escaping and the suffix under NAME_MAX. The
7605            // label is only for humans; the nonce identifies the kill domain.
7606            let mut end = spec.module_id.len().min(64);
7607            while !spec.module_id.is_char_boundary(end) {
7608                end -= 1;
7609            }
7610            format!(
7611                "{}_{suffix}",
7612                swap::cgroup_name(&spec.module_id[..end], alternate_slot)
7613            )
7614        }
7615    };
7616    #[cfg(not(target_os = "linux"))]
7617    let _ = alternate_slot;
7618    #[cfg(target_os = "macos")]
7619    let (mut command, privacy_exec, exec_ack) = privacy_command(spec, roster)?;
7620    #[cfg(not(target_os = "macos"))]
7621    let mut command = Command::new(&spec.program);
7622    command.args(&spec.args);
7623    // AMBIENT `CK_LOG` MUST NOT LEAK INTO AN OTHERWISE UNCONFIGURED MODULE — but
7624    // that is the whole of the intent, so remove that one key rather than the
7625    // environment.
7626    //
7627    // This was `env_clear()` from 0.17.41 until 0.18.3, which achieved the goal
7628    // and took the POSIX environment with it. Modules spawned that way had no
7629    // HOME, XDG_RUNTIME_DIR, TMPDIR or USER, and the consequences ran past
7630    // logging:
7631    //
7632    //   * `connection_file::discover` reads XDG_RUNTIME_DIR and HOME, so with
7633    //     both unset it fell back to the temp dir alone and `ck` could not find
7634    //     a daemon running on the same machine from inside any module's process
7635    //     tree — reporting a path the file has never lived at, which reads as
7636    //     "the daemon did not write its file".
7637    //   * `default_data_home()` with HOME and XDG_DATA_HOME both unset returns
7638    //     the RELATIVE `.local/share`, so a module deriving its own store path
7639    //     resolved it against its own CWD. That is the store-fragmentation
7640    //     defect the daemon already refuses in config (`parse_doc` rejects a
7641    //     relative `storage.data_home`) arriving by derivation instead.
7642    //   * anything a module spawns inherited it: git without ~/.gitconfig,
7643    //     cargo without CARGO_HOME, ssh, python user dirs — all degrading
7644    //     quietly rather than erroring.
7645    //
7646    // Reported by iceteaSA as #104 after deploying 0.18.2, where `ck daemon`
7647    // offered one candidate under /tmp while the file sat in /run/user/1000.
7648    //
7649    // A configured module is unaffected either way: `module_spec()` puts the
7650    // resolved CK_LOG into `spec.env`, which is applied below and therefore
7651    // wins over anything ambient.
7652    apply_child_env(&mut command, spec);
7653    apply_spawn_role(&mut command, role);
7654    let nonce_handoff =
7655        apply_wire_spawn_args_for_role(&mut command, spec, connection_file_path, handle, role)?;
7656
7657    #[cfg(target_os = "linux")]
7658    let cgroup_path = cgroup_placement
7659        .map(|placement| placement.module_path(&cgroup_name))
7660        .transpose()
7661        .map_err(|source| SuperviseError::Cgroup {
7662            module_id: spec.module_id.clone(),
7663            source,
7664        })?;
7665    #[cfg(not(target_os = "linux"))]
7666    let cgroup_path: Option<PathBuf> = None;
7667    #[cfg(target_os = "linux")]
7668    if let Some(path) = &cgroup_path {
7669        if let Err(error) = apply_cgroup_placement(&mut command, spec, path) {
7670            if let Some(placement) = cgroup_placement {
7671                remove_module_cgroup(placement, &cgroup_name);
7672            }
7673            return Err(error);
7674        }
7675    }
7676
7677    let output_sink = if let Some(logs_dir) = capture_logs_dir {
7678        let path = logs_dir.join(format!("{}.stderr.log", spec.module_id));
7679        match ChildOutputSink::open(&path, capture_retention(spec)) {
7680            Ok(sink) => sink,
7681            Err(error) => {
7682                warn!(
7683                    module_id = %spec.module_id,
7684                    path = %path.display(),
7685                    error = %error,
7686                    "could not open child output capture file; forwarding to stderr"
7687                );
7688                ChildOutputSink::Stderr
7689            }
7690        }
7691    } else {
7692        ChildOutputSink::Stderr
7693    };
7694
7695    command.stdout(Stdio::piped());
7696    command.stderr(Stdio::piped());
7697    command.kill_on_drop(true);
7698    // EACH MODULE LEADS ITS OWN PROCESS GROUP (the child calls setpgid(0, 0)
7699    // before exec). In the daemon's group, a service manager that kills the
7700    // job's process group when the daemon exits (launchd's default) killed
7701    // every module at the same moment its control connection closed, so no
7702    // module ever ran its EOF teardown on a daemon stop. Outside that group a
7703    // module is reached only by the daemon: the EOF it sees when its
7704    // connection closes, and the bounded stop in `child_roster` for anything
7705    // still running after that. On Linux this composes with the cgroup
7706    // placement above: that is a pre_exec write to cgroup.procs, std performs
7707    // setpgid in the child before running pre_exec callbacks, and the two
7708    // change independent process attributes.
7709    //
7710    // stdin is /dev/null because a process outside the terminal's foreground
7711    // group is stopped (SIGTTIN) if it reads the terminal, which a daemon run
7712    // by hand would otherwise hand down. Under a service manager stdin is
7713    // already /dev/null.
7714    #[cfg(unix)]
7715    command.process_group(0);
7716    command.stdin(Stdio::null());
7717    // The LAST pre-exec step, after the cgroup placement above: installing the
7718    // nonce at descriptor 3 replaces whatever the child had there, which could
7719    // be the descriptor an earlier step writes through.
7720    #[cfg(unix)]
7721    if let Some(handoff) = nonce_handoff {
7722        handoff.install_last(command.as_std_mut());
7723    }
7724    #[cfg(not(unix))]
7725    let _ = nonce_handoff;
7726
7727    // Containment, step 1 of 3 (issue #109): create the child suspended so it
7728    // cannot run a single instruction -- and therefore cannot spawn a
7729    // grandchild -- before it is in the job. See `contain_spawned_child` for the
7730    // other two steps and why the window matters.
7731    #[cfg(windows)]
7732    subc_jobobject::suspend_on_create_async(&mut command);
7733    let mut child = match command.spawn() {
7734        Ok(child) => child,
7735        Err(source) => {
7736            #[cfg(target_os = "linux")]
7737            if let Some(placement) = cgroup_placement {
7738                remove_module_cgroup(placement, &cgroup_name);
7739            }
7740            return Err(SuperviseError::Spawn {
7741                program: spec.program.clone(),
7742                source,
7743                cgroup_path,
7744            });
7745        }
7746    };
7747    // The parent must close its writer now: the acknowledgement pipe reports EOF
7748    // only when every writer is gone, and the child's copy closes when the
7749    // trampoline replaces itself with the module. Command holds only an integer
7750    // in its pre_exec callback, not another writer.
7751    #[cfg(target_os = "macos")]
7752    drop(exec_ack);
7753
7754    // Containment, steps 2 and 3: assign while suspended, then resume.
7755    #[cfg(windows)]
7756    let job = contain_spawned_child(&child, spec)?;
7757    let spawned_at_ms = unix_ms_now();
7758    let spawned_from = spec.program.clone();
7759    let spawned_file_identity = spawned_file_identity(&spawned_from);
7760    let pid = child.id().ok_or_else(|| SuperviseError::Spawn {
7761        program: spec.program.clone(),
7762        source: io::Error::other("spawned child exposed no live pid"),
7763        cgroup_path: cgroup_path.clone(),
7764    })?;
7765    let process_start_time = crate::provenance::process_start_time(pid);
7766    let process_identity = process_start_time.map(|start_time| ProcessIdentity { pid, start_time });
7767    #[cfg(all(test, target_os = "macos"))]
7768    privacy_exec_boundary_tests::before_image_sample(spec, pid);
7769    // Unix spawn returns after exec's error pipe closes. The kernel image is
7770    // therefore the executable to compare during a future orphan sweep: PATH
7771    // lookup and shebang interpretation may select a different file from the
7772    // configured program. Keep the literal program's identity for provenance,
7773    // but never use it as proof that a recorded pid may be signalled.
7774    let recorded_image = observe_spawned_image(pid);
7775    // spawn() confirms only the first exec, into the trampoline. Never persist
7776    // the trampoline image; the asynchronous acknowledgement publishes the
7777    // module image once the trampoline has replaced itself with the module.
7778    #[cfg(target_os = "macos")]
7779    let recorded_image = if privacy_exec.is_some() {
7780        None
7781    } else {
7782        recorded_image
7783    };
7784    #[cfg(target_os = "linux")]
7785    let recorded_cgroup_name = cgroup_path.as_ref().map(|_| cgroup_name.clone());
7786    #[cfg(not(target_os = "linux"))]
7787    let recorded_cgroup_name = None;
7788    let roster_guard = roster.admit(
7789        spec.module_id.clone(),
7790        pid,
7791        spec.protocol,
7792        process_start_time,
7793        crate::child_roster::RecordedIdentity {
7794            start_time: recorded_image.map(|image| image.start_time),
7795            executable: recorded_image
7796                .and_then(|image| image.executable)
7797                .map(crate::live_children::ExecutableIdentity::from),
7798            cgroup_name: recorded_cgroup_name,
7799            #[cfg(target_os = "linux")]
7800            cgroup_placement: cgroup_placement.cloned(),
7801        },
7802    );
7803    // The check at the top of this function can pass just before daemon
7804    // shutdown begins, and the process is only in the roster from here on.
7805    // The shutdown stop returns as soon as it finds the roster empty, so a
7806    // process admitted after that look would outlive the daemon. The roster
7807    // is closed before the stop first reads it and admission happens under
7808    // the roster's lock, so either the stop sees this process or this check
7809    // sees the roster closed: end the process now rather than start a module
7810    // the daemon is about to stop.
7811    if roster.is_closed() {
7812        // This child was never admitted, so there is no module protocol shutdown to wait for.
7813        #[cfg(target_os = "linux")]
7814        kill_module_cgroup(cgroup_placement, &cgroup_name);
7815        if let Err(error) = child.start_kill() {
7816            debug!(module_id = %spec.module_id, pid, %error, "kill of a process spawned during daemon shutdown failed; it may already have exited");
7817        }
7818        #[cfg(target_os = "linux")]
7819        if let Some(placement) = cgroup_placement {
7820            // This spawn was never admitted, so shutdown has no roster entry
7821            // to await. Do not detach its cleanup: the runtime could exit
7822            // before that task reaps the rejected child and removes its group.
7823            while matches!(child.try_wait(), Ok(None)) {
7824                std::thread::yield_now();
7825            }
7826            if matches!(
7827                subc_cgroup::kill_module(Some(placement), &cgroup_name),
7828                subc_cgroup::KillOutcome::Killed
7829            ) {
7830                if let Ok(path) = placement.module_path(&cgroup_name) {
7831                    while std::fs::read_to_string(path.join("cgroup.events"))
7832                        .ok()
7833                        .is_some_and(|events| events.lines().any(|line| line == "populated 1"))
7834                    {
7835                        std::thread::yield_now();
7836                    }
7837                }
7838            }
7839            remove_module_cgroup(placement, &cgroup_name);
7840        }
7841        drop(roster_guard);
7842        return Err(SuperviseError::Spawn {
7843            program: spec.program.clone(),
7844            source: io::Error::other(
7845                "the daemon began shutting down while this process was starting; ended it",
7846            ),
7847            cgroup_path,
7848        });
7849    }
7850
7851    let stdout_pump = match child.stdout.take() {
7852        Some(stdout) => Some(tokio::spawn(pump_stdout_to(stdout, output_sink.clone()))),
7853        None => {
7854            warn!(
7855                module_id = %spec.module_id,
7856                "spawned child exposed no stdout pipe; file capture will be incomplete"
7857            );
7858            None
7859        }
7860    };
7861    let stderr_pump = match child.stderr.take() {
7862        Some(stderr) => {
7863            let generation = ring
7864                .lock()
7865                .unwrap_or_else(|poisoned| poisoned.into_inner())
7866                .begin_process();
7867            Some(StderrPump {
7868                task: tokio::spawn(pump_stderr_to(
7869                    stderr,
7870                    Arc::clone(ring),
7871                    generation,
7872                    output_sink,
7873                )),
7874                generation,
7875            })
7876        }
7877        None => {
7878            // Spawning succeeded but the pipe did not materialise. Recording it as
7879            // uncaptured keeps the tail honest: the alternative is an empty tail
7880            // that reads as a module which printed nothing.
7881            ring.lock()
7882                .unwrap_or_else(|poisoned| poisoned.into_inner())
7883                .mark_not_captured("stderr pipe was not available on spawn");
7884            warn!(
7885                module_id = %spec.module_id,
7886                "spawned child exposed no stderr pipe; tail will be unavailable"
7887            );
7888            None
7889        }
7890    };
7891
7892    Ok(SupervisedChild {
7893        child,
7894        protocol: spec.protocol,
7895        #[cfg(target_os = "linux")]
7896        module_id: cgroup_name,
7897        #[cfg(target_os = "linux")]
7898        cgroup_placement: cgroup_placement.cloned(),
7899        #[cfg(windows)]
7900        job,
7901        stdout_pump,
7902        stderr_pump,
7903        stderr_ring: Arc::clone(ring),
7904        spawned_at_ms,
7905        spawned_from,
7906        spawned_file_identity,
7907        process_start_time,
7908        process_identity,
7909        pid,
7910        roster_guard: Some(roster_guard),
7911        #[cfg(target_os = "macos")]
7912        privacy_exec,
7913        #[cfg(target_os = "macos")]
7914        report_ready: Arc::new(OnceLock::new()),
7915        spawn_failure: None,
7916    })
7917}
7918
7919#[cfg(target_os = "linux")]
7920pub(crate) fn kill_module_cgroup(placement: Option<&subc_cgroup::Placement>, module_id: &str) {
7921    use subc_cgroup::KillOutcome;
7922    match subc_cgroup::kill_module(placement, module_id) {
7923        KillOutcome::Killed => {}
7924        KillOutcome::NotPlaced | KillOutcome::Unsupported => {
7925            debug!(
7926                module_id,
7927                "cgroup tree kill unavailable; using direct-child kill"
7928            );
7929        }
7930        KillOutcome::IoError { path, error } => {
7931            warn!(module_id, path = %path.display(), %error, "cgroup tree kill failed; using direct-child kill");
7932        }
7933    }
7934}
7935
7936/// Contain a freshly spawned Windows child and start it.
7937///
7938/// Steps 2 and 3 of the suspended-create contract: the job is created and the
7939/// child assigned **while it is still suspended** (step 1 is
7940/// `suspend_on_create_async` at the spawn site), then the child is resumed.
7941///
7942/// A child that is never resumed hangs forever holding a pid, so a resume
7943/// failure kills the child and fails the spawn rather than returning a
7944/// `SupervisedChild` that can never run.
7945///
7946/// An assignment failure is NOT fatal: an uncontained module behaves exactly as
7947/// it did before this existed, whereas refusing to start one would be a new
7948/// outage. It is logged at warn because it means a helper process could leak.
7949#[cfg(windows)]
7950fn contain_spawned_child(
7951    child: &Child,
7952    spec: &ModuleSpec,
7953) -> Result<Option<subc_jobobject::JobObject>, SuperviseError> {
7954    let module_id = spec.module_id.as_str();
7955    let Some(pid) = child.id() else {
7956        // The child exited between spawn and here. Its tree, if it made one,
7957        // needs no containment: nothing is left to contain.
7958        warn!(
7959            module_id,
7960            "spawned child had already exited before containment; no job object attached"
7961        );
7962        return Ok(None);
7963    };
7964
7965    let job = match subc_jobobject::JobObject::new() {
7966        Ok(job) => job,
7967        Err(source) => {
7968            warn!(
7969                module_id,
7970                error = %source,
7971                "could not create a job object; this module's helper processes will not be \
7972                 reaped on teardown"
7973            );
7974            // Resume regardless: leaving the child suspended would turn a
7975            // containment gap into a hung module.
7976            resume_suspended_child(pid, spec)?;
7977            return Ok(None);
7978        }
7979    };
7980
7981    if let Err(source) = job.assign(child) {
7982        warn!(
7983            module_id,
7984            error = %source,
7985            "could not assign the child to its job object; this module's helper processes \
7986             will not be reaped on teardown"
7987        );
7988        resume_suspended_child(pid, spec)?;
7989        return Ok(None);
7990    }
7991
7992    resume_suspended_child(pid, spec)?;
7993    Ok(Some(job))
7994}
7995
7996/// Resume a suspended child, killing it if it cannot be started.
7997///
7998/// A suspended process holds a pid and does nothing, so there is no useful
7999/// state to return: the caller gets an error and the spawn fails.
8000#[cfg(windows)]
8001fn resume_suspended_child(pid: u32, spec: &ModuleSpec) -> Result<(), SuperviseError> {
8002    if let Err(source) = subc_jobobject::resume_main_thread(pid) {
8003        // Kill it here rather than leaving a suspended process for the caller
8004        // to notice; `kill_on_drop` would eventually do this, but the module
8005        // would have been reported as running in between.
8006        let _ = std::process::Command::new("taskkill.exe")
8007            .args(["/PID", &pid.to_string(), "/T", "/F"])
8008            .stdin(Stdio::null())
8009            .stdout(Stdio::null())
8010            .stderr(Stdio::null())
8011            .status();
8012        return Err(SuperviseError::Spawn {
8013            program: spec.program.clone(),
8014            source,
8015            cgroup_path: None,
8016        });
8017    }
8018    Ok(())
8019}
8020
8021#[cfg(target_os = "linux")]
8022fn remove_module_cgroup(placement: &subc_cgroup::Placement, module_id: &str) {
8023    match placement.remove_module(module_id) {
8024        Ok(()) => debug!(module_id, "removed module cgroup after process exit"),
8025        Err(error) => warn!(
8026            module_id,
8027            error = %error,
8028            "could not remove module cgroup after process exit; continuing teardown"
8029        ),
8030    }
8031}
8032
8033#[cfg(target_os = "linux")]
8034async fn cleanup_reaped_cgroup(placement: &subc_cgroup::Placement, module_id: &str) {
8035    // Reaping the direct child is not proof its descendants exited. End the
8036    // residual tree and wait for the kernel's population fact before rmdir;
8037    // otherwise a successful parent wait leaks a directory on each restart.
8038    if matches!(
8039        subc_cgroup::kill_module(Some(placement), module_id),
8040        subc_cgroup::KillOutcome::Killed
8041    ) {
8042        if let Ok(path) = placement.module_path(module_id) {
8043            while std::fs::read_to_string(path.join("cgroup.events"))
8044                .ok()
8045                .is_some_and(|events| events.lines().any(|line| line == "populated 1"))
8046            {
8047                sleep(Duration::from_millis(1)).await;
8048            }
8049        }
8050    }
8051    remove_module_cgroup(placement, module_id);
8052}
8053
8054#[cfg(target_os = "linux")]
8055fn apply_cgroup_placement(
8056    command: &mut Command,
8057    spec: &ModuleSpec,
8058    path: &std::path::Path,
8059) -> Result<(), SuperviseError> {
8060    subc_cgroup::apply(command, path).map_err(|source| SuperviseError::Cgroup {
8061        module_id: spec.module_id.clone(),
8062        source,
8063    })
8064}
8065
8066fn capture_retention(spec: &ModuleSpec) -> Retention {
8067    let defaults = Retention::default();
8068    let value = |name: &str| {
8069        spec.env
8070            .iter()
8071            .rev()
8072            .find_map(|(key, value)| (key == name).then_some(value.as_str()))
8073    };
8074    Retention {
8075        max_file_mb: value(CAPTURE_MAX_FILE_MB_ENV)
8076            .and_then(|value| value.parse().ok())
8077            .unwrap_or(defaults.max_file_mb),
8078        keep: value(CAPTURE_KEEP_ENV)
8079            .and_then(|value| value.parse().ok())
8080            .unwrap_or(defaults.keep),
8081        max_age_days: value(CAPTURE_MAX_AGE_DAYS_ENV)
8082            .and_then(|value| value.parse().ok())
8083            .unwrap_or(defaults.max_age_days),
8084    }
8085}
8086
8087/// A fresh 256-bit CSPRNG launch nonce, lowercase hex. Used to bind a reserved
8088/// module's registration to the exact process the supervisor spawned.
8089fn generate_launch_nonce() -> Result<String, SuperviseError> {
8090    let mut bytes = [0u8; 32];
8091    getrandom::getrandom(&mut bytes).map_err(|source| SuperviseError::LaunchNonce {
8092        reason: source.to_string(),
8093    })?;
8094    let mut hex = String::with_capacity(64);
8095    for b in bytes {
8096        use std::fmt::Write;
8097        let _ = write!(hex, "{b:02x}");
8098    }
8099    Ok(hex)
8100}
8101
8102/// Constant-time byte comparison so a reserved-nonce mismatch leaks no timing
8103/// signal about how many leading bytes matched.
8104fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
8105    if a.len() != b.len() {
8106        return false;
8107    }
8108    let mut diff = 0u8;
8109    for (x, y) in a.iter().zip(b.iter()) {
8110        diff |= x ^ y;
8111    }
8112    diff == 0
8113}
8114
8115/// The kernel's image after an acknowledged exec, shared by ordinary launches
8116/// and privacy trampolines. PATH and shebang interpretation are kernel facts,
8117/// not identities inferred from a configured pathname.
8118fn observe_spawned_image(pid: u32) -> Option<subc_os::Observation> {
8119    subc_os::Process::open(pid)
8120        .ok()
8121        .flatten()
8122        .and_then(|process| process.observe())
8123}
8124
8125fn spawn_and_mark_running(
8126    spec: &ModuleSpec,
8127    runtime: &SupervisorRuntimeConfig,
8128    snapshot: &SharedSnapshot,
8129) -> Result<SupervisedChild, SuperviseError> {
8130    let child = spawn_child(
8131        spec,
8132        runtime.connection_file_path.as_deref(),
8133        runtime.supervisor_handle.as_ref(),
8134        &runtime.stderr_ring,
8135        runtime.capture_logs_dir.as_deref(),
8136        &runtime.child_roster,
8137        #[cfg(target_os = "linux")]
8138        runtime.cgroup_placement.as_ref(),
8139    )?;
8140    set_running(snapshot, &child, &spec.module_id, &runtime.spawn_events)?;
8141    Ok(child)
8142}
8143
8144enum RegistrationWaitOutcome {
8145    Registered,
8146    Exited(ExitReport),
8147    TimedOut,
8148}
8149
8150struct ReloadRegistrationFailure {
8151    exit_report: ExitReport,
8152    reason: String,
8153}
8154
8155#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8156enum BusyGaugeObservation {
8157    Quiescent,
8158    Busy,
8159    Omitted,
8160}
8161
8162fn busy_gauge_observation(metrics: Option<&Value>, gauges: &[String]) -> BusyGaugeObservation {
8163    let Some(metrics) = metrics.and_then(Value::as_object) else {
8164        return BusyGaugeObservation::Omitted;
8165    };
8166    let mut sum = 0u128;
8167    for gauge in gauges {
8168        let Some(value) = metrics.get(gauge) else {
8169            return BusyGaugeObservation::Omitted;
8170        };
8171        let Some(value) = value.as_u64() else {
8172            return BusyGaugeObservation::Busy;
8173        };
8174        sum = sum.saturating_add(u128::from(value));
8175    }
8176    if sum == 0 {
8177        BusyGaugeObservation::Quiescent
8178    } else {
8179        BusyGaugeObservation::Busy
8180    }
8181}
8182
8183fn declared_busy_gauges(
8184    registry: &Registry,
8185    module_id: &str,
8186) -> Result<Vec<String>, SuperviseError> {
8187    busy_gauges_of(
8188        registry
8189            .get_module(module_id)
8190            .map_err(SuperviseError::Registry)?,
8191    )
8192}
8193
8194/// [`declared_busy_gauges`] for the registration a connection holds, in any
8195/// slot: after cutover the incumbent is no longer the id's active
8196/// registration, and its own manifest is the one that names its gauges.
8197fn declared_busy_gauges_for_connection(
8198    registry: &Registry,
8199    connection_id: ConnectionId,
8200) -> Result<Vec<String>, SuperviseError> {
8201    busy_gauges_of(
8202        registry
8203            .get_module_by_connection(connection_id)
8204            .map_err(SuperviseError::Registry)?,
8205    )
8206}
8207
8208fn busy_gauges_of(
8209    registration: Option<crate::registry::ModuleRegistration>,
8210) -> Result<Vec<String>, SuperviseError> {
8211    let Some(registration) = registration else {
8212        return Ok(Vec::new());
8213    };
8214    let Some(self_signals) = registration.manifest.self_signals else {
8215        return Ok(Vec::new());
8216    };
8217
8218    let mut gauges = Vec::new();
8219    for declaration in self_signals {
8220        if declaration.kind != SelfSignalKind::Busy {
8221            continue;
8222        }
8223        match declaration.anchored_to {
8224            SignalAnchor::HealthGauges { gauges: declared } if !declared.is_empty() => {
8225                gauges.extend(declared)
8226            }
8227            _ => {
8228                // An invalid Busy anchor is fail-safe: the empty name cannot be
8229                // present in a conforming health report, so this drain stays busy.
8230                gauges.push(String::new());
8231            }
8232        }
8233    }
8234    Ok(gauges)
8235}
8236
8237/// Wait for `endpoint` to have nothing in flight and, when the module declares
8238/// busy gauges, for a health probe to report them quiet. The probe is addressed
8239/// by `scope`: a swap's superseded incumbent must be asked about its own
8240/// gauges, and by module id the probe would reach the promoted candidate.
8241async fn wait_for_forwarding_quiescence(
8242    forwarding: &ForwardingTable,
8243    module_id: &str,
8244    runtime: &SupervisorRuntimeConfig,
8245    endpoint: crate::ModuleEndpointId,
8246    deadline: Instant,
8247    busy_gauges: &[String],
8248    scope: DrainScope,
8249) -> Result<bool, SuperviseError> {
8250    let mut gauges_quiescent = busy_gauges.is_empty();
8251    let mut next_probe_at = Instant::now();
8252    let mut omission_counted = false;
8253
8254    loop {
8255        let now = Instant::now();
8256        if !busy_gauges.is_empty() && now >= next_probe_at && now < deadline {
8257            let report = match scope {
8258                DrainScope::Active => probe_module_health(module_id, runtime, Some(deadline)).await,
8259                DrainScope::Endpoint(endpoint) => {
8260                    probe_endpoint_health(endpoint, runtime, Some(deadline)).await
8261                }
8262            };
8263            gauges_quiescent = match report {
8264                Ok(report) => match busy_gauge_observation(report.metrics.as_ref(), busy_gauges) {
8265                    BusyGaugeObservation::Quiescent => true,
8266                    BusyGaugeObservation::Busy => false,
8267                    BusyGaugeObservation::Omitted => {
8268                        if !omission_counted {
8269                            forwarding
8270                                .counters()
8271                                .increment_drains_with_undeclared_gauge();
8272                            omission_counted = true;
8273                        }
8274                        false
8275                    }
8276                },
8277                Err(err) => {
8278                    warn!(
8279                        module_id,
8280                        error = %err,
8281                        "drain health.check did not produce declared busy gauges; treating module as busy"
8282                    );
8283                    false
8284                }
8285            };
8286            next_probe_at = Instant::now() + runtime.health.cadence.max(REGISTRY_RELEASE_POLL);
8287        }
8288
8289        let in_flight = forwarding
8290            .endpoint_in_flight_count(endpoint)
8291            .map_err(SuperviseError::Forwarding)?;
8292        if in_flight == 0 && gauges_quiescent {
8293            return Ok(true);
8294        }
8295
8296        let now = Instant::now();
8297        if now >= deadline {
8298            return Ok(false);
8299        }
8300        let mut wait = deadline
8301            .saturating_duration_since(now)
8302            .min(REGISTRY_RELEASE_POLL);
8303        if !busy_gauges.is_empty() {
8304            wait = wait.min(next_probe_at.saturating_duration_since(now));
8305        }
8306        sleep(wait).await;
8307    }
8308}
8309
8310/// The `route.closed` `drained` value implied by a quiescence-wait outcome.
8311///
8312/// `Ok` is always honest and passed straight through -- the wait actually measured
8313/// in-flight state. `Err` means the wait produced no measurement at all (the
8314/// forwarding table's lock was poisoned), so `false` is reported as the one honest
8315/// constant: the drain did not complete. Never recomputed from route state, never a
8316/// third "unknown" value -- the caller must still send a well-formed `route.closed`.
8317fn drained_after_quiescence_wait(wait_result: &Result<bool, SuperviseError>) -> bool {
8318    match wait_result {
8319        Ok(drained) => *drained,
8320        Err(_) => false,
8321    }
8322}
8323
8324fn send_route_goodbyes(forwarding: &ForwardingTable, released_routes: Vec<GoodbyeTarget>) {
8325    for released in released_routes {
8326        let frame = match Frame::build_with_version(
8327            released.negotiated_ver,
8328            FrameType::Goodbye,
8329            control_flags(),
8330            released.channel,
8331            released.epoch,
8332            0,
8333            Vec::new(),
8334        ) {
8335            Ok(frame) => frame,
8336            Err(err) => {
8337                warn!(
8338                    route_channel = released.channel,
8339                    error = %err,
8340                    "failed to build supervisor drain route GOODBYE frame"
8341                );
8342                continue;
8343            }
8344        };
8345        if !released.close_on_delivery_failure() {
8346            crate::forwarding::send_module_route_goodbye(
8347                &forwarding.counters(),
8348                &released.sink,
8349                frame,
8350                released.module_id.as_deref(),
8351                "supervisor drain",
8352            );
8353            continue;
8354        }
8355        if let Err(err) = released.sink.try_send(frame) {
8356            warn!(
8357                target_connection_id = released.connection_id.get(),
8358                route_channel = released.channel,
8359                error = %err,
8360                "supervisor drain route GOODBYE was not delivered to client; closing target connection"
8361            );
8362            let _ = forwarding.escalate_client_delivery_failure(
8363                released.connection_id,
8364                released.channel,
8365                released.epoch,
8366                CloseReason::new(
8367                    "route_goodbye_delivery_failed",
8368                    format!(
8369                        "failed to enqueue supervisor drain route GOODBYE for channel {}: {err}",
8370                        released.channel
8371                    ),
8372                ),
8373                crate::forwarding::UndeliveredFrame {
8374                    module_id: released.module_id.as_deref(),
8375                    sink: &released.sink,
8376                },
8377            );
8378        }
8379    }
8380}
8381
8382fn send_module_draining(
8383    module_id: &str,
8384    reason: RouteCloseReason,
8385    deadline_ms: u64,
8386    target: &ModuleDrainTarget,
8387) {
8388    let body = match serde_json::to_vec(&ModuleControlCommand::Draining {
8389        reason,
8390        deadline_ms,
8391    }) {
8392        Ok(body) => body,
8393        Err(err) => {
8394            warn!(
8395                module_id,
8396                error = %err,
8397                "failed to encode module draining command"
8398            );
8399            return;
8400        }
8401    };
8402    let frame = match Frame::build_with_version(
8403        target.negotiated_ver,
8404        FrameType::Push,
8405        control_flags(),
8406        0,
8407        0,
8408        0,
8409        body,
8410    ) {
8411        Ok(frame) => frame,
8412        Err(err) => {
8413            warn!(
8414                module_id,
8415                error = %err,
8416                "failed to build module draining command frame"
8417            );
8418            return;
8419        }
8420    };
8421    if let Err(err) = target.sink.try_send(frame) {
8422        warn!(
8423            module_id,
8424            target_connection_id = target.endpoint.connection_id.get(),
8425            error = %err,
8426            "module draining command was not delivered to peer"
8427        );
8428    }
8429}
8430
8431/// The channel-0 GOODBYE that tells a module its stop is planned.
8432fn module_goodbye_frame(module_id: &str, negotiated_ver: u8) -> Option<Frame> {
8433    match Frame::build_with_version(
8434        negotiated_ver,
8435        FrameType::Goodbye,
8436        control_flags(),
8437        0,
8438        0,
8439        0,
8440        Vec::new(),
8441    ) {
8442        Ok(frame) => Some(frame),
8443        Err(err) => {
8444            warn!(
8445                module_id,
8446                error = %err,
8447                "failed to build module GOODBYE frame"
8448            );
8449            None
8450        }
8451    }
8452}
8453
8454/// Send every registered module connection its module GOODBYE at daemon
8455/// shutdown, then request that connection's close.
8456///
8457/// A module tells a planned stop from a lost daemon by whether a GOODBYE came
8458/// before EOF, so the GOODBYE must reach the socket before the close. A close
8459/// request does not wait for the connection's queued frames: its writer gets a
8460/// bounded grace after the close, is aborted if it overruns it, and the daemon
8461/// process may exit before that grace ends. So with `wait_for_flush`, each
8462/// connection is closed only after its writer has acknowledged writing the
8463/// GOODBYE, or once a short shared budget runs out, so one module that is not
8464/// reading cannot hold up the others or the shutdown. Without it the GOODBYEs
8465/// are only queued, for a shutdown the operator has told to stop waiting.
8466/// A connection that is already gone is skipped.
8467#[cfg(unix)]
8468async fn send_module_goodbyes_for_daemon_shutdown(
8469    forwarding: &Arc<ForwardingTable>,
8470    reason: &CloseReason,
8471    wait_for_flush: bool,
8472) {
8473    const GOODBYE_BUDGET: Duration = Duration::from_millis(500);
8474    let targets = match forwarding.module_connections() {
8475        Ok(targets) => targets,
8476        Err(err) => {
8477            warn!(error = %err, "could not list module connections for shutdown GOODBYE");
8478            return;
8479        }
8480    };
8481    let deadline = Instant::now() + GOODBYE_BUDGET;
8482    let mut sends = tokio::task::JoinSet::new();
8483    for target in targets {
8484        let Some(frame) = module_goodbye_frame(&target.module_id, target.negotiated_ver) else {
8485            continue;
8486        };
8487        if !wait_for_flush {
8488            if let Err(err) = target.sink.try_send(frame) {
8489                debug!(
8490                    module_id = %target.module_id,
8491                    error = %err,
8492                    "shutdown module GOODBYE was not queued"
8493                );
8494            }
8495            continue;
8496        }
8497        let forwarding = Arc::clone(forwarding);
8498        let reason = reason.clone();
8499        sends.spawn(async move {
8500            match timeout_at(deadline, target.sink.send_flushed(frame)).await {
8501                Ok(Ok(())) => {}
8502                Ok(Err(err)) => debug!(
8503                    module_id = %target.module_id,
8504                    error = %err,
8505                    "module connection closed before its shutdown GOODBYE was written"
8506                ),
8507                Err(_) => warn!(
8508                    module_id = %target.module_id,
8509                    budget = ?GOODBYE_BUDGET,
8510                    "shutdown module GOODBYE was not written within its budget; closing anyway"
8511                ),
8512            }
8513            forwarding.request_connection_close(target.endpoint.connection_id, reason);
8514        });
8515    }
8516    // Every task ends by the shared deadline, so this wait is bounded too.
8517    while sends.join_next().await.is_some() {}
8518}
8519
8520fn send_module_goodbye(module_id: &str, forwarding: &ForwardingTable, target: &ModuleDrainTarget) {
8521    let Some(frame) = module_goodbye_frame(module_id, target.negotiated_ver) else {
8522        return;
8523    };
8524    if let Err(err) = target.sink.try_send(frame) {
8525        warn!(
8526            module_id,
8527            target_connection_id = target.endpoint.connection_id.get(),
8528            error = %err,
8529            "supervisor drain module GOODBYE was not delivered to peer; closing module connection"
8530        );
8531        forwarding.request_connection_close(
8532            target.endpoint.connection_id,
8533            CloseReason::new(
8534                "module_goodbye_delivery_failed",
8535                format!("failed to enqueue supervisor drain module GOODBYE for module '{module_id}': {err}"),
8536            ),
8537        );
8538    }
8539}
8540
8541#[derive(Clone, Copy)]
8542struct ForwardingDrainContext<'a> {
8543    spec: &'a ModuleSpec,
8544    runtime: &'a SupervisorRuntimeConfig,
8545    registry: &'a Registry,
8546    scope: DrainScope,
8547}
8548
8549/// Which process a forwarding drain addresses.
8550#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8551enum DrainScope {
8552    /// Whatever endpoint is active for the module id: every plain stop,
8553    /// restart and reload. Also moves the module's state to `Draining`.
8554    Active,
8555    /// One specific endpoint: a swap's incumbent after cutover. Draining it by
8556    /// module id would resolve to the promoted candidate and leave neither
8557    /// process routable. The module's state is left alone, since the promoted
8558    /// candidate is what it describes and that process is running.
8559    Endpoint(crate::ModuleEndpointId),
8560}
8561
8562/// Whether a child being drained has already been asked to stop by the time
8563/// its drain wait starts.
8564///
8565/// The drain wait is the same budget whatever this says. What it decides is
8566/// whether the supervisor must ask by signal before that wait begins: a child
8567/// that nobody asked will sit out the whole budget and then be SIGKILLed,
8568/// healthy or not.
8569#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8570enum StopNotice {
8571    /// The module was sent `module.draining` and a module GOODBYE over its own
8572    /// registered connection, and stops itself.
8573    SentOverConnection,
8574    /// The forwarding drain found no registered connection for the module: a
8575    /// subc child spawned moments ago that has not sent HELLO yet, or a
8576    /// `protocol: "none"` child, which never registers.
8577    NoConnection,
8578    /// This path sends nothing over the module's connection: the supervisor has
8579    /// no forwarding table, or the caller stops the child without a forwarding
8580    /// drain.
8581    NotSent,
8582}
8583
8584async fn begin_forwarding_drain(
8585    spec: &ModuleSpec,
8586    runtime: &SupervisorRuntimeConfig,
8587    registry: &Registry,
8588    snapshot: &SharedSnapshot,
8589    enabled: Option<bool>,
8590    reason: RouteCloseReason,
8591) -> Result<StopNotice, SuperviseError> {
8592    let Some(forwarding) = runtime.forwarding.as_ref() else {
8593        return Err(SuperviseError::ReloadUnavailable {
8594            module_id: spec.module_id.clone(),
8595            reason: "supervisor was not configured with a forwarding table".to_string(),
8596        });
8597    };
8598
8599    begin_forwarding_drain_with(
8600        forwarding,
8601        ForwardingDrainContext {
8602            spec,
8603            runtime,
8604            registry,
8605            scope: DrainScope::Active,
8606        },
8607        snapshot,
8608        enabled,
8609        reason,
8610        runtime.drain_timeout,
8611    )
8612    .await
8613}
8614
8615async fn begin_forwarding_drain_if_configured(
8616    spec: &ModuleSpec,
8617    runtime: &SupervisorRuntimeConfig,
8618    registry: &Registry,
8619    snapshot: &SharedSnapshot,
8620    enabled: Option<bool>,
8621    reason: RouteCloseReason,
8622) -> Result<StopNotice, SuperviseError> {
8623    begin_forwarding_drain_with_timeout(
8624        spec,
8625        runtime,
8626        registry,
8627        snapshot,
8628        enabled,
8629        reason,
8630        runtime.drain_timeout,
8631    )
8632    .await
8633}
8634
8635/// Like [`begin_forwarding_drain_if_configured`] but with an explicit drain
8636/// budget, for paths where the operator overrides the module's configured one
8637/// (`supervisor.restart{drain_timeout_ms}`).
8638async fn begin_forwarding_drain_with_timeout(
8639    spec: &ModuleSpec,
8640    runtime: &SupervisorRuntimeConfig,
8641    registry: &Registry,
8642    snapshot: &SharedSnapshot,
8643    enabled: Option<bool>,
8644    reason: RouteCloseReason,
8645    drain_timeout: Duration,
8646) -> Result<StopNotice, SuperviseError> {
8647    let Some(forwarding) = runtime.forwarding.as_ref() else {
8648        return Ok(StopNotice::NotSent);
8649    };
8650
8651    begin_forwarding_drain_with(
8652        forwarding,
8653        ForwardingDrainContext {
8654            spec,
8655            runtime,
8656            registry,
8657            scope: DrainScope::Active,
8658        },
8659        snapshot,
8660        enabled,
8661        reason,
8662        drain_timeout,
8663    )
8664    .await
8665}
8666
8667async fn begin_forwarding_drain_with(
8668    forwarding: &ForwardingTable,
8669    context: ForwardingDrainContext<'_>,
8670    snapshot: &SharedSnapshot,
8671    enabled: Option<bool>,
8672    reason: RouteCloseReason,
8673    drain_timeout: Duration,
8674) -> Result<StopNotice, SuperviseError> {
8675    let ForwardingDrainContext {
8676        spec,
8677        runtime,
8678        registry,
8679        scope,
8680    } = context;
8681    debug_assert_ne!(reason, RouteCloseReason::Crash);
8682    let terminal = matches!(reason, RouteCloseReason::Disable);
8683    let drain_started_at = Instant::now();
8684    let drain_deadline = drain_started_at + drain_timeout;
8685    let deadline_ms =
8686        unix_ms_now().saturating_add(u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX));
8687    let busy_gauges = match scope {
8688        DrainScope::Active => declared_busy_gauges(registry, &spec.module_id)?,
8689        DrainScope::Endpoint(endpoint) => {
8690            declared_busy_gauges_for_connection(registry, endpoint.connection_id)?
8691        }
8692    };
8693
8694    // Admission gate first: route.open/commit and route REQUEST admission are closed
8695    // before the first quiescence check, so the outstanding count can only fall.
8696    let gate_started = Instant::now();
8697    let drain_target = match scope {
8698        DrainScope::Active => forwarding.begin_module_drain(&spec.module_id, reason),
8699        DrainScope::Endpoint(endpoint) => forwarding.begin_endpoint_drain(endpoint, reason),
8700    }
8701    .map_err(SuperviseError::Forwarding)?;
8702    // The instant admission closed, and how long taking the forwarding write
8703    // lock to close it took. The timeout line reports only the quiescence
8704    // wait, so without this a drain that started late looked like one that
8705    // started on time.
8706    info!(
8707        module_id = %spec.module_id,
8708        ?reason,
8709        gate_ms = u64::try_from(gate_started.elapsed().as_millis()).unwrap_or(u64::MAX),
8710        connected = drain_target.is_some(),
8711        "module drain began; route admission closed"
8712    );
8713    if scope == DrainScope::Active {
8714        update_snapshot(snapshot, Some(&spec.module_id), |state| {
8715            state.state = ModuleState::Draining;
8716            state.draining_to_replace =
8717                matches!(reason, RouteCloseReason::Restart | RouteCloseReason::Reload);
8718            if let Some(enabled) = enabled {
8719                state.enabled = enabled;
8720            }
8721        })?;
8722    }
8723
8724    let Some(target) = drain_target.as_ref() else {
8725        // Nothing was sent: the module has no registered connection to carry
8726        // `module.draining` or a GOODBYE. The caller must not assume the child
8727        // was asked to stop.
8728        return Ok(StopNotice::NoConnection);
8729    };
8730    {
8731        send_module_draining(&spec.module_id, reason, deadline_ms, target);
8732        let routes = forwarding
8733            .endpoint_routes(target.endpoint)
8734            .map_err(SuperviseError::Forwarding)?;
8735        let routes_notified = routes.len();
8736        crate::control::send_route_control_pushes(
8737            forwarding,
8738            routes.clone(),
8739            ClientControlPush::RouteClosing {
8740                module_id: spec.module_id.clone(),
8741                channels: Vec::new(),
8742                reason,
8743            },
8744        );
8745        send_route_goodbyes(forwarding, target.abandoned_bindings.clone());
8746
8747        // `route.closing` was just sent above: from here on every return path,
8748        // including an early one, MUST send `route.closed` before propagating
8749        // anything else. A client holds `closing` as a promise that a verdict is
8750        // coming; leaving early without `closed` strands it waiting forever, since
8751        // `closing` carries no timeout of its own.
8752        let wait_result = wait_for_forwarding_quiescence(
8753            forwarding,
8754            &spec.module_id,
8755            runtime,
8756            target.endpoint,
8757            drain_deadline,
8758            &busy_gauges,
8759            scope,
8760        )
8761        .await;
8762        let drained = drained_after_quiescence_wait(&wait_result);
8763        if let Err(err) = &wait_result {
8764            error!(
8765                module_id = %spec.module_id,
8766                ?reason,
8767                error = %err,
8768                "forwarding quiescence wait failed after route.closing; forcing route.closed(drained: false) so the client is not left waiting on an unfulfilled promise"
8769            );
8770        } else if !drained {
8771            // Name what the drain waited on. Without it the line says only that
8772            // something did not settle, and "one wedged call" and "every
8773            // session's held stream" read the same; the first is a module bug,
8774            // the second is a module that should end its streams on
8775            // module.draining. Read before teardown releases the routes.
8776            let holdouts = forwarding
8777                .endpoint_drain_holdouts(target.endpoint)
8778                .unwrap_or_default();
8779            warn!(
8780                module_id = %spec.module_id,
8781                waited = ?drain_timeout,
8782                ?reason,
8783                held_requests = holdouts.requests,
8784                held_routes = holdouts.routes,
8785                total_routes = holdouts.total_routes,
8786                top_connections = ?holdouts.top_connections,
8787                // `module_channel:corr`, so the module can find each held request
8788                // in its own log; capped, so `held_requests` is the full count.
8789                held = %holdouts
8790                    .held
8791                    .iter()
8792                    .map(|(channel, corr)| format!("{channel}:{corr}"))
8793                    .collect::<Vec<_>>()
8794                    .join(","),
8795                "route drain timed out before request quiescence; forcing teardown"
8796            );
8797        }
8798        crate::control::send_route_control_pushes(
8799            forwarding,
8800            routes,
8801            ClientControlPush::RouteClosed {
8802                module_id: spec.module_id.clone(),
8803                channels: Vec::new(),
8804                reason,
8805                drained,
8806                abandoned: target.abandoned_bindings.len() as u32,
8807                excluded_subscriptions: target.excluded_subscriptions,
8808                terminal: Some(terminal),
8809            },
8810        );
8811        wait_result?;
8812
8813        // `route.closed` has now been sent unconditionally above. From here the
8814        // remaining steps are cleanup (route + module GOODBYE) rather than a
8815        // promise the client is waiting on, but a lock-poisoned
8816        // `release_module_endpoint_routes` would otherwise skip the module
8817        // GOODBYE silently too -- send it before propagating the error.
8818        let released_routes = match forwarding.release_module_endpoint_routes(target.endpoint) {
8819            Ok(routes) => routes,
8820            Err(err) => {
8821                warn!(
8822                    module_id = %spec.module_id,
8823                    ?reason,
8824                    error = %err,
8825                    "failed to release module endpoint routes after route.closed; module GOODBYE will still be sent"
8826                );
8827                send_module_goodbye(&spec.module_id, forwarding, target);
8828                return Err(SuperviseError::Forwarding(err));
8829            }
8830        };
8831        let route_goodbye_count = released_routes.len();
8832        send_route_goodbyes(forwarding, released_routes);
8833        send_module_goodbye(&spec.module_id, forwarding, target);
8834
8835        // The drain's happy path was previously silent: every emission above is
8836        // best-effort with only its failure arm logged, so "were consumers told"
8837        // was unprovable from the daemon log (surfaced by a 30-minute consumer
8838        // hang where the open question was exactly whether teardown notice went
8839        // out). One summary line makes that class decidable in one grep.
8840        info!(
8841            module_id = %spec.module_id,
8842            ?reason,
8843            routes_notified,
8844            route_goodbyes = route_goodbye_count,
8845            abandoned_reservations = target.abandoned_bindings.len(),
8846            excluded_subscriptions = target.excluded_subscriptions,
8847            drained,
8848            "module drain complete; consumers notified via route.closing/route.closed pushes and per-route GOODBYE frames"
8849        );
8850    }
8851
8852    Ok(StopNotice::SentOverConnection)
8853}
8854
8855/// Wait for the freshly spawned child to take the ACTIVE slot for `module_id`,
8856/// the only slot a plain (non-swap) spawn can register into.
8857async fn wait_for_registration_after_reload(
8858    registry: &Registry,
8859    module_id: &str,
8860    snapshot: &SharedSnapshot,
8861    child: &mut SupervisedChild,
8862    wait: Duration,
8863) -> Result<RegistrationWaitOutcome, SuperviseError> {
8864    wait_for_slot_registration(
8865        registry,
8866        crate::registry::RegistrationSlot::Active(module_id),
8867        module_id,
8868        snapshot,
8869        child,
8870        wait,
8871    )
8872    .await
8873}
8874
8875/// Wait for `child` to register into `slot`, or to exit, or for `wait` to pass.
8876///
8877/// Keyed on the slot rather than the bare module id because during a swap the
8878/// id's active slot is already held by the incumbent: an id-keyed wait would
8879/// report the incumbent's registration as the candidate's and a candidate that
8880/// never registers would look registered. A swap candidate waits on
8881/// `crate::registry::RegistrationSlot::Candidate`.
8882async fn wait_for_slot_registration(
8883    registry: &Registry,
8884    slot: crate::registry::RegistrationSlot<'_>,
8885    module_id: &str,
8886    snapshot: &SharedSnapshot,
8887    child: &mut SupervisedChild,
8888    wait: Duration,
8889) -> Result<RegistrationWaitOutcome, SuperviseError> {
8890    let deadline = Instant::now() + wait;
8891    loop {
8892        if registry
8893            .registration(slot)
8894            .map_err(SuperviseError::Registry)?
8895            .is_some()
8896        {
8897            return Ok(RegistrationWaitOutcome::Registered);
8898        }
8899
8900        let now = Instant::now();
8901        if now >= deadline {
8902            return Ok(RegistrationWaitOutcome::TimedOut);
8903        }
8904        let remaining = deadline.saturating_duration_since(now);
8905        let poll = remaining.min(REGISTRY_RELEASE_POLL);
8906
8907        tokio::select! {
8908            wait_result = child.wait() => {
8909                let status = wait_result.map_err(|source| SuperviseError::Wait {
8910                    module_id: module_id.to_string(),
8911                    source,
8912                })?;
8913                return Ok(RegistrationWaitOutcome::Exited(classify_reaped_child_exit(
8914                    snapshot,
8915                    child,
8916                    &status,
8917                )));
8918            }
8919            _ = sleep(poll) => {}
8920        }
8921    }
8922}
8923
8924fn registration_failure_exit_report(mut exit_report: ExitReport) -> ExitReport {
8925    // A replacement process that exits before HELLO did not provide service, even
8926    // if it used status 0. Count it against the restart cap as a new-binary failure.
8927    if exit_report.kind != ExitKind::DeliberateSeverance {
8928        exit_report.kind = ExitKind::Crash;
8929    }
8930    exit_report
8931}
8932
8933async fn handle_reload_child_registration_failure(
8934    spec: &ModuleSpec,
8935    runtime: &SupervisorRuntimeConfig,
8936    registry: &Registry,
8937    process_liveness: &SupervisorProcessLiveness,
8938    snapshot: &SharedSnapshot,
8939    _child: &mut Option<SupervisedChild>,
8940    failure: ReloadRegistrationFailure,
8941) -> Result<(), SuperviseError> {
8942    let ReloadRegistrationFailure {
8943        exit_report,
8944        reason,
8945    } = failure;
8946    match on_child_exit(
8947        spec,
8948        runtime.restart_policy,
8949        registry,
8950        snapshot,
8951        &runtime.terminal_ring,
8952        &runtime.spawn_events,
8953        &runtime.child_roster,
8954        exit_report,
8955    )
8956    .await
8957    {
8958        NextAction::Stop {
8959            registration_released,
8960        } => {
8961            if registration_released {
8962                process_liveness.untrack_if_current(&spec.module_id, snapshot);
8963            }
8964        }
8965        NextAction::Restart { schedule } => {
8966            let delay = schedule.map_or(runtime.restart_policy.delay_for_restart(0), |schedule| {
8967                schedule.delay
8968            });
8969            if let Some(schedule) = schedule {
8970                log_crash_respawn(&spec.module_id, schedule);
8971            }
8972            schedule_respawn(
8973                runtime,
8974                snapshot,
8975                &spec.module_id,
8976                delay,
8977                RespawnKind::Spawn,
8978            )?;
8979        }
8980    }
8981    Err(SuperviseError::ReloadFailed {
8982        module_id: spec.module_id.clone(),
8983        reason,
8984    })
8985}
8986
8987async fn handle_reload_spawn_failure(
8988    spec: &ModuleSpec,
8989    runtime: &SupervisorRuntimeConfig,
8990    process_liveness: &SupervisorProcessLiveness,
8991    snapshot: &SharedSnapshot,
8992    _child: &mut Option<SupervisedChild>,
8993    reason: String,
8994) -> Result<(), SuperviseError> {
8995    let now = Instant::now();
8996    let mut schedule = None;
8997    update_snapshot(snapshot, Some(&spec.module_id), |state| {
8998        clear_current_process_facts(state);
8999        if state.enabled {
9000            schedule = state.next_crash_restart(&runtime.restart_policy, now);
9001            state.state = if schedule.is_some() {
9002                ModuleState::Restarting
9003            } else {
9004                ModuleState::Failed
9005            };
9006        } else {
9007            state.state = ModuleState::Disabled;
9008        }
9009    })?;
9010    if let Some(schedule) = schedule {
9011        schedule_respawn(
9012            runtime,
9013            snapshot,
9014            &spec.module_id,
9015            schedule.delay,
9016            RespawnKind::Spawn,
9017        )?;
9018    } else {
9019        process_liveness.untrack_if_current(&spec.module_id, snapshot);
9020    }
9021    Err(SuperviseError::ReloadFailed {
9022        module_id: spec.module_id.clone(),
9023        reason,
9024    })
9025}
9026
9027fn control_flags() -> Flags {
9028    Flags::new(false, Priority::Passive, false)
9029}
9030
9031#[allow(clippy::too_many_arguments)]
9032async fn drain_optional_child(
9033    module_id: &str,
9034    protocol: ModuleProtocol,
9035    stop_notice: StopNotice,
9036    registry: &Registry,
9037    forwarding: Option<&ForwardingTable>,
9038    snapshot: &SharedSnapshot,
9039    terminal_ring: &Arc<Mutex<TerminalRing>>,
9040    spawn_events: &SpawnEventFeed,
9041    child: &mut Option<SupervisedChild>,
9042    drain_timeout: Duration,
9043    final_state: ModuleState,
9044    enabled: Option<bool>,
9045) -> Result<(), SuperviseError> {
9046    if let Some(child) = child.take() {
9047        drain_child_to_state(
9048            module_id,
9049            protocol,
9050            stop_notice,
9051            registry,
9052            forwarding,
9053            snapshot,
9054            terminal_ring,
9055            spawn_events,
9056            child,
9057            drain_timeout,
9058            final_state,
9059            enabled,
9060        )
9061        .await
9062    } else {
9063        update_snapshot(snapshot, Some(module_id), |state| {
9064            state.state = final_state;
9065            if let Some(enabled) = enabled {
9066                state.enabled = enabled;
9067            }
9068            clear_current_process_facts(state);
9069        })?;
9070        release_dead_registration(registry, forwarding, snapshot, module_id).await
9071    }
9072}
9073
9074#[allow(clippy::too_many_arguments)]
9075async fn drain_child_to_state(
9076    module_id: &str,
9077    _protocol: ModuleProtocol,
9078    stop_notice: StopNotice,
9079    registry: &Registry,
9080    forwarding: Option<&ForwardingTable>,
9081    snapshot: &SharedSnapshot,
9082    terminal_ring: &Arc<Mutex<TerminalRing>>,
9083    spawn_events: &SpawnEventFeed,
9084    mut child: SupervisedChild,
9085    drain_timeout: Duration,
9086    final_state: ModuleState,
9087    enabled: Option<bool>,
9088) -> Result<(), SuperviseError> {
9089    let protocol = child.protocol;
9090    update_snapshot(snapshot, Some(module_id), |state| {
9091        state.state = ModuleState::Draining;
9092        state.draining_to_replace = final_state == ModuleState::Restarting;
9093        if let Some(enabled) = enabled {
9094            state.enabled = enabled;
9095        }
9096    })?;
9097
9098    // The wait below is the same budget in every case; what differs is
9099    // whether anything has ASKED the child to stop before it starts. Only a
9100    // forwarding drain that reached the module's registered connection has
9101    // (`module.draining`, then a module GOODBYE). Every other child was told
9102    // nothing: a `protocol: "none"` module, which never registers; a subc
9103    // module spawned moments ago that has not sent HELLO yet; or a stop that
9104    // runs no forwarding drain. Without a signal the budget is only a delay
9105    // in front of SIGKILL -- and the not-yet-registered child is the worst
9106    // case, because it registers into a module that is already draining,
9107    // is never told, and is killed while healthy.
9108    if stop_notice != StopNotice::SentOverConnection {
9109        if protocol == ModuleProtocol::Subc && stop_notice == StopNotice::NoConnection {
9110            info!(
9111                module_id,
9112                pid = child.pid,
9113                budget_ms = u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX),
9114                "module has no connection yet; requesting stop by signal"
9115            );
9116        }
9117        request_graceful_stop(module_id, &child);
9118    }
9119
9120    let exit_report = match timeout(drain_timeout, child.wait()).await {
9121        Ok(Ok(status)) => classify_reaped_child_exit(snapshot, &child, &status),
9122        Ok(Err(source)) => {
9123            fail_snapshot(snapshot, Some(module_id), None);
9124            return Err(SuperviseError::Wait {
9125                module_id: module_id.to_string(),
9126                source,
9127            });
9128        }
9129        Err(_) => {
9130            // Mirror the sibling arm above: state is already `Draining`, and an
9131            // error propagated from here would strand it there -- a state
9132            // `set_enabled(true)` cannot heal (`revive_terminal` matches only
9133            // `Failed | Stopped`), leaving an operator Restart as the only exit.
9134            // `Failed` before `?` keeps the module operator-visible and
9135            // revivable. Trigger is an ESRCH race (process exits between the
9136            // drain timeout firing and the kill) or a post-kill wait failure
9137            // (issue #34).
9138            //
9139            // Logged because the kill is otherwise visible only as signal 9 in
9140            // the terminal ring, and the budget it follows can be long enough
9141            // that consumers see a stretch of refusals with no stated cause.
9142            warn!(
9143                module_id,
9144                pid = child.pid,
9145                budget_ms = u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX),
9146                reason = ?final_state,
9147                ?stop_notice,
9148                "drain budget expired before the module exited; killing it"
9149            );
9150            child.start_kill().map_err(|source| {
9151                fail_snapshot(snapshot, Some(module_id), None);
9152                SuperviseError::Kill {
9153                    module_id: module_id.to_string(),
9154                    source,
9155                }
9156            })?;
9157            let status = child.wait().await.map_err(|source| {
9158                fail_snapshot(snapshot, Some(module_id), None);
9159                SuperviseError::Wait {
9160                    module_id: module_id.to_string(),
9161                    source,
9162                }
9163            })?;
9164            classify_reaped_child_exit(snapshot, &child, &status)
9165        }
9166    };
9167
9168    update_snapshot(snapshot, Some(module_id), |state| {
9169        state.state = final_state;
9170        if let Some(enabled) = enabled {
9171            state.enabled = enabled;
9172        }
9173        clear_current_process_facts(state);
9174        state.last_exit = Some(exit_report.clone());
9175        if exit_report.kind == ExitKind::DeliberateSeverance {
9176            state.lifetime_restarts += 1;
9177        }
9178    })?;
9179    let detail = lock_snapshot(snapshot)?.drain_disposition_detail.take();
9180    record_terminal_with_detail(
9181        module_id,
9182        terminal_ring,
9183        spawn_events,
9184        &exit_report,
9185        terminal_disposition(final_state),
9186        detail,
9187    );
9188    child.drain_stderr(module_id).await;
9189
9190    release_dead_registration(registry, forwarding, snapshot, module_id).await
9191}
9192
9193/// Ask a child that nothing else has asked to stop, by signal.
9194///
9195/// A registered subc module is asked over its own connection: the drain sends
9196/// `route.closing`/`route.closed` to its consumers, a GOODBYE per route, then a
9197/// module GOODBYE, and the module stops itself. A module that speaks no subc
9198/// wire receives none of that, and neither does a subc module that has not
9199/// registered yet, so for them the drain budget would be pure delay in front of
9200/// a SIGKILL -- and for a process with a store to flush (JetStream is the
9201/// reason `protocol: "none"` exists) a SIGKILL turns every ordinary teardown
9202/// into a recovery on the next start.
9203///
9204/// NEVER CALLED FOR A MODULE THAT WAS TOLD OVER ITS CONNECTION, and that is a
9205/// rule rather than an optimisation: that module's graceful stop is already
9206/// running by the time its child is drained, and a signal would race it.
9207///
9208/// Best-effort by construction. A child that has already exited is the ordinary
9209/// case rather than an error (the kill lands on a reaped or exiting pid), so a
9210/// failure is logged at debug and the wait-then-kill below still decides the
9211/// outcome.
9212#[cfg(unix)]
9213fn request_graceful_stop(module_id: &str, child: &SupervisedChild) {
9214    let Some(pid) = child
9215        .id()
9216        .and_then(|pid| i32::try_from(pid).ok())
9217        .and_then(rustix::process::Pid::from_raw)
9218    else {
9219        debug!(
9220            module_id,
9221            "no pid to signal for teardown; falling through to the drain wait"
9222        );
9223        return;
9224    };
9225    match rustix::process::kill_process(pid, rustix::process::Signal::TERM) {
9226        Ok(()) => debug!(
9227            module_id,
9228            "sent SIGTERM to a module nothing else asked to stop"
9229        ),
9230        Err(err) => debug!(
9231            module_id,
9232            error = %err,
9233            "SIGTERM to module failed; the drain wait and kill still apply"
9234        ),
9235    }
9236}
9237
9238/// Windows has no SIGTERM and no portable stand-in for one. The graceful stops
9239/// Windows does offer need cooperation this supervisor cannot assume: a console
9240/// control event requires sharing a console with the child, and `WM_CLOSE`
9241/// requires the child to pump a message loop. A supervised server process does
9242/// neither, so there is nothing to send and teardown is the wait followed by the
9243/// kill. Emulating a signal here would mean inventing a stop protocol, which is
9244/// the thing `protocol: "none"` exists to avoid.
9245#[cfg(not(unix))]
9246fn request_graceful_stop(module_id: &str, _child: &SupervisedChild) {
9247    debug!(
9248        module_id,
9249        "no graceful stop signal exists on this platform; teardown of a module nothing asked to stop waits, then kills"
9250    );
9251}
9252
9253fn terminal_disposition(final_state: ModuleState) -> TerminalDisposition {
9254    match final_state {
9255        ModuleState::Stopped => TerminalDisposition::Stopped,
9256        ModuleState::Disabled => TerminalDisposition::Disabled,
9257        ModuleState::Restarting => TerminalDisposition::Restarting,
9258        ModuleState::Failed => TerminalDisposition::Failed,
9259        ModuleState::Starting
9260        | ModuleState::Running
9261        | ModuleState::Unresponsive
9262        | ModuleState::Draining => {
9263            unreachable!("terminal exits only finish in terminal or restarting states")
9264        }
9265    }
9266}
9267
9268/// Release a reaped child's registration before allowing another spawn.
9269///
9270/// EOF is not a process-lifetime signal: an inherited socket can stay open
9271/// indefinitely, and serial frame dispatch can be waiting on egress instead of
9272/// reading EOF. After the normal release grace, request connection close (which
9273/// cancels both reads and dispatch), then allow one more release grace for the
9274/// connection guard's forwarding cleanup. Never evict a different connection.
9275async fn release_dead_registration(
9276    registry: &Registry,
9277    forwarding: Option<&ForwardingTable>,
9278    snapshot: &SharedSnapshot,
9279    module_id: &str,
9280) -> Result<(), SuperviseError> {
9281    let result = async {
9282        let registration = registry
9283            .get_module(module_id)
9284            .map_err(SuperviseError::Registry)?;
9285        match wait_for_registration_release(registry, module_id, REGISTRY_RELEASE_TIMEOUT).await {
9286            Ok(()) => return Ok(()),
9287            Err(SuperviseError::RegistrationStillActive { .. }) => {}
9288            Err(err) => return Err(err),
9289        }
9290        let pid = lock_snapshot(snapshot)?.reaped_pid;
9291        if let (Some(registration), Some(forwarding), Some(pid)) = (registration, forwarding, pid) {
9292            warn!(
9293                module_id,
9294                pid,
9295                connection_id = registration.connection_id.get(),
9296                "reaped module registration outlived release grace; closing dead connection"
9297            );
9298            forwarding.request_connection_close(
9299                registration.connection_id,
9300                CloseReason::new(
9301                    "supervised_process_reaped",
9302                    format!("module '{module_id}' pid {pid} exited"),
9303                ),
9304            );
9305            wait_for_slot_registration_release(
9306                registry,
9307                crate::registry::RegistrationSlot::Connection(registration.connection_id),
9308                REGISTRY_RELEASE_TIMEOUT,
9309            )
9310            .await?;
9311        }
9312        wait_for_registration_release(registry, module_id, Duration::ZERO).await
9313    }
9314    .await;
9315    if let Err(err) = &result {
9316        fail_snapshot(snapshot, Some(module_id), None);
9317        error!(module_id, error = %err, "registration release failed after child exit; module is failed and start can retry");
9318    }
9319    result
9320}
9321
9322/// Wait for the ACTIVE registration of `module_id` to go away, which is what a
9323/// plain stop or restart waits for before it spawns a replacement.
9324async fn wait_for_registration_release(
9325    registry: &Registry,
9326    module_id: &str,
9327    wait: Duration,
9328) -> Result<(), SuperviseError> {
9329    wait_for_slot_registration_release(
9330        registry,
9331        crate::registry::RegistrationSlot::Active(module_id),
9332        wait,
9333    )
9334    .await
9335}
9336
9337/// Wait for the registration in `slot` to go away.
9338///
9339/// Keyed on the slot rather than the bare module id because a successful swap
9340/// never empties the id's active slot (the promoted candidate is in it), so an
9341/// id-keyed wait for the incumbent's release would always time out. Draining a
9342/// swap's incumbent waits on `crate::registry::RegistrationSlot::Connection` with the
9343/// incumbent's connection instead.
9344async fn wait_for_slot_registration_release(
9345    registry: &Registry,
9346    slot: crate::registry::RegistrationSlot<'_>,
9347    wait: Duration,
9348) -> Result<(), SuperviseError> {
9349    let deadline = Instant::now() + wait;
9350    let mut release_events = registration_release_events().subscribe();
9351    let still_active = |registration: &crate::registry::ModuleRegistration| {
9352        SuperviseError::RegistrationStillActive {
9353            module_id: registration.manifest.module_id.clone(),
9354            waited: wait,
9355        }
9356    };
9357    loop {
9358        let _observed_generation = *release_events.borrow_and_update();
9359        let Some(registration) = registry
9360            .registration(slot)
9361            .map_err(SuperviseError::Registry)?
9362        else {
9363            return Ok(());
9364        };
9365
9366        let now = Instant::now();
9367        if now >= deadline {
9368            return Err(still_active(&registration));
9369        }
9370
9371        let remaining = deadline.saturating_duration_since(now);
9372        match timeout(remaining, release_events.changed()).await {
9373            Ok(Ok(())) | Ok(Err(_)) => {}
9374            Err(_) => return Err(still_active(&registration)),
9375        }
9376    }
9377}
9378
9379#[cfg(test)]
9380mod slot_registration_wait_tests {
9381    use super::*;
9382    use crate::registry::{ConnectionId, RegistrationSlot};
9383    use subc_protocol::manifest::ModuleManifest;
9384
9385    #[tokio::test]
9386    async fn enable_release_failure_is_failed_and_a_second_enable_retries() {
9387        let registry = Arc::new(Registry::default());
9388        let supervisor = Supervisor::new_for_test(Arc::clone(&registry), RestartPolicy::default());
9389        let runtime = supervisor.runtime_config();
9390        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::disabled()));
9391        let spec = ModuleSpec {
9392            module_id: "enable-stale-registration".to_string(),
9393            program: PathBuf::from("/missing/enable-retry-test"),
9394            args: Vec::new(),
9395            env: Vec::new(),
9396            reserved: false,
9397            reserved_prefixes: Vec::new(),
9398            protocol: ModuleProtocol::Subc,
9399            overlap: Default::default(),
9400        };
9401        let connection = ConnectionId::new(90);
9402        registry
9403            .register_with_control_ops(
9404                ModuleManifest::builder(&spec.module_id, "0.1.0").build(),
9405                1,
9406                connection,
9407                Vec::new(),
9408            )
9409            .unwrap();
9410        let mut child = None;
9411        let err = set_child_enabled(
9412            &spec,
9413            &runtime,
9414            &registry,
9415            &supervisor.process_liveness,
9416            &snapshot,
9417            &mut child,
9418            true,
9419        )
9420        .await
9421        .unwrap_err();
9422        assert!(matches!(
9423            err,
9424            SuperviseError::RegistrationStillActive { .. }
9425        ));
9426        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
9427        assert!(child.is_none());
9428        registry.deregister_connection(connection).unwrap();
9429        let err = set_child_enabled(
9430            &spec,
9431            &runtime,
9432            &registry,
9433            &supervisor.process_liveness,
9434            &snapshot,
9435            &mut child,
9436            true,
9437        )
9438        .await
9439        .unwrap_err();
9440        assert!(
9441            matches!(err, SuperviseError::Spawn { .. }),
9442            "second enable must attempt a spawn: {err}"
9443        );
9444        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
9445    }
9446
9447    const INCUMBENT: u64 = 1;
9448    const CANDIDATE: u64 = 2;
9449
9450    fn swapped_registry() -> Arc<Registry> {
9451        let registry = Arc::new(Registry::default());
9452        let manifest = ModuleManifest::builder("m", "0.1.0").build();
9453        registry
9454            .register_with_control_ops(
9455                manifest.clone(),
9456                1,
9457                ConnectionId::new(INCUMBENT),
9458                Vec::new(),
9459            )
9460            .unwrap();
9461        registry
9462            .register_candidate_with_control_ops(
9463                manifest,
9464                1,
9465                ConnectionId::new(CANDIDATE),
9466                Vec::new(),
9467            )
9468            .unwrap();
9469        registry
9470    }
9471
9472    /// After a promotion the id's active slot is held by the new process, so an
9473    /// id-keyed wait for the incumbent's release can never succeed; the
9474    /// connection-keyed wait completes as soon as the incumbent deregisters.
9475    #[tokio::test]
9476    async fn incumbent_release_is_awaited_by_connection_not_by_module_id() {
9477        let registry = swapped_registry();
9478        registry.promote_candidate("m").unwrap().unwrap();
9479
9480        assert!(matches!(
9481            wait_for_registration_release(&registry, "m", Duration::from_millis(50)).await,
9482            Err(SuperviseError::RegistrationStillActive { .. })
9483        ));
9484
9485        // Still held while the incumbent's connection has not deregistered.
9486        assert!(matches!(
9487            wait_for_slot_registration_release(
9488                &registry,
9489                RegistrationSlot::Connection(ConnectionId::new(INCUMBENT)),
9490                Duration::from_millis(50),
9491            )
9492            .await,
9493            Err(SuperviseError::RegistrationStillActive { .. })
9494        ));
9495
9496        let releaser = Arc::clone(&registry);
9497        let release = tokio::spawn(async move {
9498            sleep(Duration::from_millis(20)).await;
9499            releaser
9500                .deregister_connection(ConnectionId::new(INCUMBENT))
9501                .unwrap();
9502            notify_registration_release();
9503        });
9504        wait_for_slot_registration_release(
9505            &registry,
9506            RegistrationSlot::Connection(ConnectionId::new(INCUMBENT)),
9507            Duration::from_secs(5),
9508        )
9509        .await
9510        .expect("the incumbent's own registration is released");
9511        release.await.unwrap();
9512        assert!(registry.get_module("m").unwrap().is_some());
9513    }
9514
9515    /// The candidate slot is waited on separately from the active slot: the
9516    /// incumbent's registration neither holds up nor stands in for it.
9517    #[tokio::test]
9518    async fn candidate_slot_wait_ignores_the_incumbents_registration() {
9519        let registry = swapped_registry();
9520        assert!(matches!(
9521            wait_for_slot_registration_release(
9522                &registry,
9523                RegistrationSlot::Candidate("m"),
9524                Duration::from_millis(50),
9525            )
9526            .await,
9527            Err(SuperviseError::RegistrationStillActive { .. })
9528        ));
9529        registry
9530            .deregister_connection(ConnectionId::new(CANDIDATE))
9531            .unwrap();
9532        wait_for_slot_registration_release(
9533            &registry,
9534            RegistrationSlot::Candidate("m"),
9535            Duration::from_millis(50),
9536        )
9537        .await
9538        .expect("a candidate slot with no candidate is released");
9539        assert!(registry
9540            .registration(RegistrationSlot::Active("m"))
9541            .unwrap()
9542            .is_some());
9543    }
9544}
9545
9546fn classify_exit(status: &ExitStatus) -> ExitReport {
9547    ExitReport {
9548        kind: if status.success() {
9549            ExitKind::Clean
9550        } else {
9551            ExitKind::Crash
9552        },
9553        code: status.code(),
9554        signal: exit_signal(status),
9555        at_ms: unix_ms_now(),
9556    }
9557}
9558
9559/// The terminal record for a module whose `wait()` call itself errored (e.g. the
9560/// child was already reaped out-of-band). There is no `ExitStatus` to read a code
9561/// or signal from -- `None`/`None` is the honest shape, not a guess -- but the
9562/// disposition still must be `Failed` so the terminal ring is not silently missing
9563/// an entry, matching what `fail_snapshot` records for this same arm.
9564fn wait_error_exit_report() -> ExitReport {
9565    ExitReport {
9566        kind: ExitKind::Crash,
9567        code: None,
9568        signal: None,
9569        at_ms: unix_ms_now(),
9570    }
9571}
9572
9573#[cfg(unix)]
9574fn exit_signal(status: &ExitStatus) -> Option<i32> {
9575    use std::os::unix::process::ExitStatusExt;
9576
9577    status.signal()
9578}
9579
9580#[cfg(not(unix))]
9581fn exit_signal(_status: &ExitStatus) -> Option<i32> {
9582    None
9583}
9584
9585/// Give an operator-touched module its full crash budget back.
9586///
9587/// Named for the counter it used to zero; it now empties the in-window ring,
9588/// which is the same act. `lifetime_restarts` is untouched on purpose -- the
9589/// ledger of what happened survives every operator action.
9590fn reset_restart_count(snapshot: &SharedSnapshot, module_id: &str) -> Result<(), SuperviseError> {
9591    update_snapshot(snapshot, Some(module_id), |state| {
9592        state.clear_crash_restarts();
9593    })
9594}
9595
9596fn set_running(
9597    snapshot: &SharedSnapshot,
9598    child: &SupervisedChild,
9599    module_id: &str,
9600    spawn_events: &SpawnEventFeed,
9601) -> Result<(), SuperviseError> {
9602    let mut state = snapshot.lock().map_err(|_| SuperviseError::StatePoisoned {
9603        module_id: Some(module_id.to_string()),
9604    })?;
9605    state.spawn_generation = spawn_events.emit_spawned(module_id, child.pid, child.spawned_at_ms);
9606    if std::mem::take(&mut state.coalesced_restart_pending) {
9607        let generation = state.spawn_generation;
9608        info!(module_id, "restart already satisfied by generation {generation}; coalesced pending request completed");
9609    }
9610    state.drain_disposition_detail = None;
9611    state.spawn_failure = None;
9612    // Every caller of this is a plain spawn, which always uses the primary key;
9613    // a promoted swap candidate sets the flag itself after this returns.
9614    state.in_alternate_slot = false;
9615    state.configuration_updated_since_spawn = false;
9616    state.spawned_protocol = Some(child.protocol);
9617    state.state = ModuleState::Running;
9618    state.enabled = true;
9619    state.process_alive = true;
9620    state.pid = child.id();
9621    #[cfg(target_os = "macos")]
9622    {
9623        state.report_ready = Some(Arc::clone(&child.report_ready));
9624    }
9625    state.spawned_at_ms = Some(child.spawned_at_ms);
9626    state.spawned_from = Some(child.spawned_from.clone());
9627    state.spawned_file_identity = child.spawned_file_identity;
9628    state.process_start_time = child.process_start_time;
9629    Ok(())
9630}
9631
9632fn clear_current_process_facts(state: &mut SupervisorSnapshot) {
9633    state.process_alive = false;
9634    state.spawned_protocol = None;
9635    state.pid = None;
9636    #[cfg(target_os = "macos")]
9637    {
9638        state.report_ready = None;
9639    }
9640    state.spawned_at_ms = None;
9641    state.spawned_from = None;
9642    state.spawned_file_identity = None;
9643    state.process_start_time = None;
9644    state.deliberate_severance = None;
9645}
9646
9647#[cfg(test)]
9648fn record_deliberate_severance(
9649    snapshot: &SharedSnapshot,
9650    identity: ProcessIdentity,
9651) -> Result<(), SuperviseError> {
9652    update_snapshot(snapshot, None, |state| {
9653        state.deliberate_severance = Some(identity);
9654    })
9655}
9656
9657fn apply_deliberate_severance_marker(
9658    snapshot: &SharedSnapshot,
9659    exited_identity: Option<ProcessIdentity>,
9660    mut exit_report: ExitReport,
9661) -> ExitReport {
9662    let marker = lock_snapshot(snapshot)
9663        .ok()
9664        .and_then(|mut state| state.deliberate_severance.take());
9665    if marker.is_some() && marker == exited_identity {
9666        exit_report.kind = ExitKind::DeliberateSeverance;
9667    }
9668    exit_report
9669}
9670
9671fn classify_reaped_child_exit(
9672    snapshot: &SharedSnapshot,
9673    child: &SupervisedChild,
9674    status: &ExitStatus,
9675) -> ExitReport {
9676    let _ = update_snapshot(snapshot, None, |state| {
9677        state.reaped_pid = Some(child.pid);
9678        state.spawn_failure = child.spawn_failure.clone();
9679    });
9680    apply_deliberate_severance_marker(snapshot, child.process_identity(), classify_exit(status))
9681}
9682
9683fn fail_snapshot(
9684    snapshot: &SharedSnapshot,
9685    module_id: Option<&str>,
9686    last_exit: Option<ExitReport>,
9687) {
9688    if let Err(err) = update_snapshot(snapshot, module_id, |state| {
9689        state.state = ModuleState::Failed;
9690        clear_current_process_facts(state);
9691        if let Some(last_exit) = last_exit {
9692            state.last_exit = Some(last_exit);
9693        }
9694    }) {
9695        error!(error = %err, "failed to mark supervisor state failed");
9696    }
9697}
9698
9699fn update_snapshot(
9700    snapshot: &SharedSnapshot,
9701    module_id: Option<&str>,
9702    update: impl FnOnce(&mut SupervisorSnapshot),
9703) -> Result<(), SuperviseError> {
9704    let mut state = snapshot.lock().map_err(|_| SuperviseError::StatePoisoned {
9705        module_id: module_id.map(ToOwned::to_owned),
9706    })?;
9707    update(&mut state);
9708    Ok(())
9709}
9710
9711const SLOW_SNAPSHOT_LOCK_THRESHOLD: Duration = Duration::from_millis(250);
9712
9713fn lock_snapshot_for_control<'a>(
9714    snapshot: &'a SharedSnapshot,
9715    module_id: &str,
9716    caller: &'static str,
9717) -> Result<std::sync::MutexGuard<'a, SupervisorSnapshot>, SuperviseError> {
9718    let started_at = Instant::now();
9719    let guard = lock_snapshot(snapshot)?;
9720    let waited = started_at.elapsed();
9721    if waited >= SLOW_SNAPSHOT_LOCK_THRESHOLD {
9722        warn!(
9723            module_id = %module_id,
9724            waited_ms = waited.as_millis() as u64,
9725            caller = %caller,
9726            "slow snapshot lock"
9727        );
9728    }
9729    Ok(guard)
9730}
9731
9732fn lock_snapshot(
9733    snapshot: &SharedSnapshot,
9734) -> Result<std::sync::MutexGuard<'_, SupervisorSnapshot>, SuperviseError> {
9735    snapshot
9736        .lock()
9737        .map_err(|_| SuperviseError::StatePoisoned { module_id: None })
9738}
9739
9740#[cfg(test)]
9741mod terminal_history_tests {
9742    use std::{
9743        path::PathBuf,
9744        sync::Arc,
9745        time::{Duration, Instant},
9746    };
9747
9748    use tokio::{sync::mpsc, time::sleep};
9749
9750    use super::{
9751        apply_deliberate_severance_marker, daemon_will_restart, drain_child_to_state,
9752        drained_after_quiescence_wait, handle_reload_spawn_failure, health_restart_child,
9753        lock_snapshot, on_child_exit, record_deliberate_severance, record_wait_error_terminal,
9754        reset_restart_count, spawn_and_mark_running, update_snapshot, wait_error_exit_report,
9755        ExitKind, ExitReport, ModuleProtocol, ModuleSpec, ModuleState, NextAction, ProcessIdentity,
9756        RestartPolicy, SpawnEventKind, StopNotice, SuperviseError, SupervisedConfiguration,
9757        SupervisedModule, SupervisedModuleInner, Supervisor, SupervisorHandle,
9758        SupervisorHealthStatus, SupervisorSnapshot,
9759    };
9760    // The supervisor's clock, distinct from the `std::time::Instant` these tests
9761    // use for their own wall-clock deadlines: crash-restart instants must be on
9762    // the same clock the production code stamps them with, which is tokio's (and
9763    // is what `start_paused` tests can move).
9764    use super::Instant as ClockInstant;
9765    use crate::{
9766        registry::Registry,
9767        terminal_ring::{TerminalRing, TerminalRingConfig},
9768    };
9769    use std::sync::Mutex;
9770    use subc_control::TerminalDisposition;
9771
9772    /// See the twin in `control.rs` for why this derives the path from
9773    /// `current_exe()` and why the existence check is here: `--lib` alone does
9774    /// not build `[[bin]]` targets, and a bare spawn then fails with a raw
9775    /// `NotFound` that reads as a broken test rather than an unbuilt dependency.
9776    pub(super) fn fake_aft_stub_path() -> PathBuf {
9777        let mut path = std::env::current_exe().expect("current_exe available in tests");
9778        path.pop();
9779        path.pop();
9780        path.push(if cfg!(windows) {
9781            "fake-aft-stub.exe"
9782        } else {
9783            "fake-aft-stub"
9784        });
9785        assert!(
9786            path.exists(),
9787            "fake-aft-stub not built at {}: run `cargo test -p subc-core` (which builds \
9788             [[bin]] targets) rather than `cargo test -p subc-core --lib` (which does not)",
9789            path.display()
9790        );
9791        path
9792    }
9793
9794    #[test]
9795    fn reserved_never_spawned_refuses_every_hello() {
9796        // The canary hole: a reserved id whose module has never spawned had NO
9797        // gate entry and admitted anyone -- the reservation protected the nonce
9798        // holder, not the NAME. Now the entry is present with no legitimate
9799        // holder and refuses all comers.
9800        let supervisor = SupervisorHandle::default();
9801        supervisor.apply_identity_configuration(&ModuleSpec {
9802            module_id: "never-spawned".to_string(),
9803            program: PathBuf::from("/usr/bin/false"),
9804            args: Vec::new(),
9805            env: Vec::new(),
9806            reserved: true,
9807            reserved_prefixes: Vec::new(),
9808            protocol: ModuleProtocol::Subc,
9809            overlap: Default::default(),
9810        });
9811        assert!(
9812            supervisor
9813                .reserved_hello_rejection("never-spawned", Some("any-forged-nonce"))
9814                .is_some(),
9815            "forged nonce must refuse on a reserved never-spawned id"
9816        );
9817        assert!(
9818            supervisor
9819                .reserved_hello_rejection("never-spawned", None)
9820                .is_some(),
9821            "absent nonce must refuse on a reserved never-spawned id"
9822        );
9823        // And a real spawn nonce minted later admits exactly that nonce.
9824        supervisor.set_spawn_nonce("never-spawned", "minted".to_string());
9825        supervisor.apply_identity_configuration(&ModuleSpec {
9826            module_id: "never-spawned".to_string(),
9827            program: PathBuf::from("/usr/bin/false"),
9828            args: Vec::new(),
9829            env: Vec::new(),
9830            reserved: true,
9831            reserved_prefixes: Vec::new(),
9832            protocol: ModuleProtocol::Subc,
9833            overlap: Default::default(),
9834        });
9835        assert!(supervisor
9836            .reserved_hello_rejection("never-spawned", Some("minted"))
9837            .is_none());
9838        assert!(supervisor
9839            .reserved_hello_rejection("never-spawned", Some("forged"))
9840            .is_some());
9841    }
9842
9843    /// Put `count` crash restarts on a snapshot's ring as if they had all just
9844    /// happened, which is what "spent budget" looks like to every reader.
9845    fn seed_crash_restarts(state: &mut SupervisorSnapshot, count: u32) {
9846        let now = ClockInstant::now();
9847        for _ in 0..count {
9848            state.crash_restarts.push_back(now);
9849        }
9850    }
9851
9852    /// Age the oldest recorded restart out of `window`, standing in for the hours
9853    /// that would otherwise have to pass. Injecting the instant is the point: a
9854    /// test that slept a real window would take ten minutes and still prove less.
9855    fn age_oldest_crash_restart_out_of_window(state: &mut SupervisorSnapshot, window: Duration) {
9856        let aged = state
9857            .crash_restarts
9858            .front()
9859            .expect("a crash restart must be recorded before it can be aged")
9860            .checked_sub(window + Duration::from_secs(1))
9861            .expect("the test clock is far enough from its origin to age an instant");
9862        state.crash_restarts[0] = aged;
9863    }
9864
9865    fn snapshot_with_restarts(enabled: bool, count: u32) -> SupervisorSnapshot {
9866        let mut state = SupervisorSnapshot::new(ModuleState::Running, enabled);
9867        seed_crash_restarts(&mut state, count);
9868        state
9869    }
9870
9871    #[test]
9872    fn daemon_owned_recovery_predicate_uses_the_pre_increment_budget() {
9873        let policy = RestartPolicy::new(3, Duration::ZERO);
9874        let now = ClockInstant::now();
9875        assert!(daemon_will_restart(
9876            &mut snapshot_with_restarts(true, 2),
9877            &policy,
9878            now
9879        ));
9880        assert!(!daemon_will_restart(
9881            &mut snapshot_with_restarts(true, 3),
9882            &policy,
9883            now
9884        ));
9885        assert!(!daemon_will_restart(
9886            &mut snapshot_with_restarts(false, 0),
9887            &policy,
9888            now
9889        ));
9890    }
9891
9892    #[test]
9893    fn crash_restart_backoff_escalates_with_in_window_count() {
9894        let policy = RestartPolicy::new(4, Duration::from_millis(100))
9895            .with_max_backoff(Duration::from_secs(30));
9896        let now = ClockInstant::now();
9897        let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
9898        let schedules = (0..4)
9899            .map(|_| {
9900                state
9901                    .next_crash_restart(&policy, now)
9902                    .expect("the test policy allows four crash restarts")
9903            })
9904            .collect::<Vec<_>>();
9905
9906        assert_eq!(
9907            schedules
9908                .iter()
9909                .map(|schedule| schedule.restart_in_window)
9910                .collect::<Vec<_>>(),
9911            vec![0, 1, 2, 3]
9912        );
9913        assert_eq!(
9914            schedules
9915                .iter()
9916                .map(|schedule| schedule.delay)
9917                .collect::<Vec<_>>(),
9918            vec![
9919                Duration::from_millis(100),
9920                Duration::from_secs(1),
9921                Duration::from_secs(10),
9922                Duration::from_secs(30),
9923            ]
9924        );
9925    }
9926
9927    #[test]
9928    fn crash_restart_backoff_resets_after_ring_clear() {
9929        let policy = RestartPolicy::new(3, Duration::from_millis(100));
9930        let now = ClockInstant::now();
9931        let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
9932        assert_eq!(
9933            state.next_crash_restart(&policy, now).unwrap().delay,
9934            Duration::from_millis(100)
9935        );
9936        assert_eq!(
9937            state.next_crash_restart(&policy, now).unwrap().delay,
9938            Duration::from_secs(1)
9939        );
9940
9941        state.clear_crash_restarts();
9942        let schedule = state
9943            .next_crash_restart(&policy, now)
9944            .expect("a cleared ring must allow another restart");
9945        assert_eq!(schedule.restart_in_window, 0);
9946        assert_eq!(schedule.delay, Duration::from_millis(100));
9947    }
9948
9949    #[test]
9950    fn crash_restart_backoff_ignores_aged_restarts() {
9951        let policy = RestartPolicy::new(3, Duration::from_millis(100));
9952        let now = ClockInstant::now();
9953        let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
9954        state
9955            .next_crash_restart(&policy, now)
9956            .expect("the first restart is allowed");
9957        state
9958            .next_crash_restart(&policy, now)
9959            .expect("the second restart is allowed");
9960        state.crash_restarts[0] = now
9961            .checked_sub(policy.window + Duration::from_secs(1))
9962            .expect("the fake clock can age a restart past the window");
9963
9964        let schedule = state
9965            .next_crash_restart(&policy, now)
9966            .expect("an aged restart must release its slot");
9967        assert_eq!(schedule.restart_in_window, 1);
9968        assert_eq!(schedule.delay, Duration::from_secs(1));
9969        assert_eq!(state.crash_restarts.len(), 2);
9970    }
9971
9972    /// The budget is a rate: the same three spent restarts refuse a respawn
9973    /// while they are recent and allow one once they have aged past the window.
9974    /// Nothing about the module changed in between, which is the whole point.
9975    #[test]
9976    fn a_budget_spent_before_the_window_no_longer_refuses() {
9977        let policy = RestartPolicy::new(3, Duration::ZERO);
9978        let mut state = snapshot_with_restarts(true, 3);
9979        let now = ClockInstant::now();
9980        assert!(!daemon_will_restart(&mut state, &policy, now));
9981
9982        assert!(daemon_will_restart(
9983            &mut state,
9984            &policy,
9985            now + policy.window + Duration::from_secs(1)
9986        ));
9987        assert!(
9988            state.crash_restarts.is_empty(),
9989            "reading the budget must drop the instants that left the window"
9990        );
9991    }
9992
9993    fn module_with_recovery_snapshot(
9994        state: ModuleState,
9995        enabled: bool,
9996        restart_count: u32,
9997    ) -> SupervisedModule {
9998        let registry = Arc::new(Registry::default());
9999        let supervisor =
10000            Supervisor::new_for_test(Arc::clone(&registry), RestartPolicy::new(3, Duration::ZERO));
10001        let runtime = supervisor.runtime_config();
10002        let spec = ModuleSpec {
10003            module_id: "recovery-snapshot".to_string(),
10004            program: fake_aft_stub_path(),
10005            args: Vec::new(),
10006            env: Vec::new(),
10007            reserved: false,
10008            reserved_prefixes: Vec::new(),
10009            protocol: ModuleProtocol::Subc,
10010            overlap: Default::default(),
10011        };
10012        let mut snapshot = SupervisorSnapshot::new(state, enabled);
10013        seed_crash_restarts(&mut snapshot, restart_count);
10014        // These tests read synthetic snapshots. A real child and monitor would
10015        // race those reads by replacing the requested state during startup.
10016        let (commands, _rx) = mpsc::channel(4);
10017        SupervisedModule {
10018            inner: Arc::new(SupervisedModuleInner {
10019                module_id: spec.module_id.clone(),
10020                registry,
10021                snapshot: Arc::new(Mutex::new(snapshot)),
10022                configuration: Arc::new(Mutex::new(SupervisedConfiguration {
10023                    spec,
10024                    health: runtime.health,
10025                })),
10026                stderr_ring: runtime.stderr_ring,
10027                terminal_ring: runtime.terminal_ring,
10028                commands,
10029                monitor: Mutex::new(None),
10030                restart_policy: runtime.restart_policy,
10031                effective_drain_timeout: runtime.effective_drain_timeout,
10032                provenance_probe: supervisor.provenance_probe.clone(),
10033            }),
10034        }
10035    }
10036
10037    #[cfg(target_os = "linux")]
10038    #[tokio::test]
10039    async fn no_cgroup_placement_does_not_block_fake_aft_stub_spawn() {
10040        let supervisor =
10041            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
10042                .with_cgroup_placement(None);
10043        let result = supervisor.spawn(ModuleSpec {
10044            module_id: "no-cgroup-placement".to_string(),
10045            program: fake_aft_stub_path(),
10046            args: Vec::new(),
10047            env: Vec::new(),
10048            reserved: false,
10049            reserved_prefixes: Vec::new(),
10050            protocol: ModuleProtocol::Subc,
10051            overlap: Default::default(),
10052        });
10053
10054        assert!(
10055            result.is_ok(),
10056            "no delegation must not turn an otherwise valid spawn into a failure: {result:?}"
10057        );
10058    }
10059
10060    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10061    async fn undecided_snapshot_uses_shared_restart_predicate() {
10062        assert!(module_with_recovery_snapshot(ModuleState::Running, true, 2)
10063            .will_recover_after_connection_loss()
10064            .unwrap());
10065        assert!(
10066            !module_with_recovery_snapshot(ModuleState::Running, true, 3)
10067                .will_recover_after_connection_loss()
10068                .unwrap()
10069        );
10070    }
10071
10072    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10073    async fn restarting_snapshot_at_exhausted_budget_is_non_terminal() {
10074        assert!(
10075            module_with_recovery_snapshot(ModuleState::Restarting, true, 3)
10076                .will_recover_after_connection_loss()
10077                .unwrap()
10078        );
10079    }
10080
10081    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10082    async fn terminal_phase_snapshots_are_terminal_before_budget_exhaustion() {
10083        assert!(!module_with_recovery_snapshot(ModuleState::Failed, true, 0)
10084            .will_recover_after_connection_loss()
10085            .unwrap());
10086        assert!(
10087            !module_with_recovery_snapshot(ModuleState::Disabled, true, 0)
10088                .will_recover_after_connection_loss()
10089                .unwrap()
10090        );
10091    }
10092
10093    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10094    async fn warming_snapshot_is_limited_to_startup_phases() {
10095        for state in [
10096            ModuleState::Starting,
10097            ModuleState::Running,
10098            ModuleState::Restarting,
10099        ] {
10100            assert!(
10101                module_with_recovery_snapshot(state, true, 0)
10102                    .is_warming()
10103                    .unwrap(),
10104                "{state:?} should be warming"
10105            );
10106        }
10107        for state in [
10108            ModuleState::Unresponsive,
10109            ModuleState::Draining,
10110            ModuleState::Stopped,
10111            ModuleState::Failed,
10112            ModuleState::Disabled,
10113        ] {
10114            assert!(
10115                !module_with_recovery_snapshot(state, true, 0)
10116                    .is_warming()
10117                    .unwrap(),
10118                "{state:?} should not be warming"
10119            );
10120        }
10121    }
10122
10123    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10124    async fn terminal_history_survives_respawn_and_keeps_both_crashes_in_order() {
10125        let registry = Arc::new(Registry::default());
10126        let supervisor =
10127            Supervisor::new_for_test(Arc::clone(&registry), RestartPolicy::new(1, Duration::ZERO));
10128        let module = supervisor
10129            .spawn(ModuleSpec {
10130                module_id: "terminal-history".to_string(),
10131                program: fake_aft_stub_path(),
10132                args: Vec::new(),
10133                env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
10134                reserved: false,
10135                reserved_prefixes: Vec::new(),
10136                protocol: ModuleProtocol::Subc,
10137                overlap: Default::default(),
10138            })
10139            .unwrap();
10140
10141        let deadline = Instant::now() + Duration::from_secs(5);
10142        loop {
10143            let history = module.terminal_history();
10144            if history.entries.len() == 2 {
10145                assert_eq!(module.status().unwrap().state, ModuleState::Failed);
10146                assert_eq!(history.dropped, 0);
10147                assert_eq!(
10148                    history
10149                        .entries
10150                        .iter()
10151                        .map(|entry| entry.exit_code)
10152                        .collect::<Vec<_>>(),
10153                    vec![Some(23), Some(23)]
10154                );
10155                assert!(history.entries[0].at_ms <= history.entries[1].at_ms);
10156                return;
10157            }
10158            assert!(
10159                Instant::now() < deadline,
10160                "module did not retain two terminal exits: {history:?}"
10161            );
10162            sleep(Duration::from_millis(10)).await;
10163        }
10164    }
10165
10166    /// A disable issued while a crash respawn is still backing off must preempt
10167    /// that respawn: the operator's stop wins, the disable must not queue behind
10168    /// the backoff, and the module must never come back up afterwards.
10169    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10170    async fn disable_during_crash_backoff_cancels_pending_respawn() {
10171        let backoff = Duration::from_secs(2);
10172        let supervisor = Supervisor::new_for_test(
10173            Arc::new(Registry::default()),
10174            RestartPolicy::new(10, backoff),
10175        );
10176        let module = supervisor
10177            .spawn(ModuleSpec {
10178                module_id: "disable-during-backoff".to_string(),
10179                program: fake_aft_stub_path(),
10180                args: Vec::new(),
10181                env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
10182                reserved: false,
10183                reserved_prefixes: Vec::new(),
10184                protocol: ModuleProtocol::Subc,
10185                overlap: Default::default(),
10186            })
10187            .unwrap();
10188
10189        // Wait for the first crash to put the module into its backoff window.
10190        let deadline = Instant::now() + Duration::from_secs(5);
10191        loop {
10192            if module.status().unwrap().state == ModuleState::Restarting {
10193                break;
10194            }
10195            assert!(
10196                Instant::now() < deadline,
10197                "module never entered the crash backoff"
10198            );
10199            sleep(Duration::from_millis(10)).await;
10200        }
10201
10202        let started = Instant::now();
10203        module.set_enabled(false).await.unwrap();
10204        let waited = started.elapsed();
10205
10206        assert!(
10207            waited < backoff / 2,
10208            "disable waited {waited:?} behind the {backoff:?} crash backoff; the operator command must preempt the pending respawn"
10209        );
10210        assert_eq!(module.status().unwrap().state, ModuleState::Disabled);
10211
10212        // Outlast the backoff: the respawn it was counting down to must never run.
10213        sleep(backoff + Duration::from_millis(500)).await;
10214        let status = module.status().unwrap();
10215        assert_eq!(status.state, ModuleState::Disabled);
10216        assert_eq!(
10217            status.spawn_generation, 1,
10218            "module respawned after the operator disabled it"
10219        );
10220    }
10221
10222    /// A `protocol: "none"` child that stays parked and exits 0 on SIGTERM:
10223    /// the shape of nats-server, the program this rule exists for.
10224    #[cfg(unix)]
10225    fn protocol_none_sigterm_exits_clean_spec(
10226        module_id: &str,
10227        dir: &std::path::Path,
10228    ) -> (ModuleSpec, PathBuf, PathBuf) {
10229        let ready = dir.join("ready");
10230        let marker = dir.join("sigterm");
10231        let spec = ModuleSpec {
10232            module_id: module_id.to_string(),
10233            program: fake_aft_stub_path(),
10234            args: Vec::new(),
10235            env: vec![
10236                ("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string()),
10237                (
10238                    "FAKE_AFT_SIGTERM_MARKER_PATH".to_string(),
10239                    marker.display().to_string(),
10240                ),
10241                (
10242                    "FAKE_AFT_NEVER_CONNECT_READY_PATH".to_string(),
10243                    ready.display().to_string(),
10244                ),
10245            ],
10246            reserved: false,
10247            reserved_prefixes: Vec::new(),
10248            protocol: ModuleProtocol::None,
10249            overlap: Default::default(),
10250        };
10251        (spec, ready, marker)
10252    }
10253
10254    /// Wait for a file the child writes, so a signal is never sent before the
10255    /// child's SIGTERM handler is installed (the default disposition would
10256    /// kill it by signal and the exit would not be clean).
10257    #[cfg(unix)]
10258    async fn wait_for_file(path: &std::path::Path) {
10259        let deadline = Instant::now() + Duration::from_secs(10);
10260        while !path.exists() {
10261            assert!(
10262                Instant::now() < deadline,
10263                "{} never appeared",
10264                path.display()
10265            );
10266            sleep(Duration::from_millis(10)).await;
10267        }
10268    }
10269
10270    /// A protocol-none module that exits 0 because something OUTSIDE the
10271    /// supervisor sent it SIGTERM is respawned, and the terminal record carries
10272    /// the crash-path disposition rather than `stopped`.
10273    #[cfg(unix)]
10274    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10275    async fn protocol_none_unrequested_clean_exit_restarts_as_a_crash() {
10276        let dir = cortexkit_test_support::ScratchDir::new("none-unrequested-clean-exit");
10277        let (spec, ready, marker) =
10278            protocol_none_sigterm_exits_clean_spec("none-unrequested-clean-exit", dir.path());
10279        let supervisor = Supervisor::new_for_test(
10280            Arc::new(Registry::default()),
10281            RestartPolicy::new(3, Duration::ZERO),
10282        );
10283        let module = supervisor.spawn(spec).unwrap();
10284        wait_for_file(&ready).await;
10285        // The ready file proves the child installed its SIGTERM handler, not
10286        // that the supervisor has processed the privacy trampoline's exec
10287        // acknowledgement. On macOS status withholds the pid until then.
10288        let deadline = Instant::now() + Duration::from_secs(10);
10289        let first_pid = loop {
10290            let status = module.status().unwrap();
10291            if status.state == ModuleState::Running {
10292                if let Some(pid) = status.pid {
10293                    break pid;
10294                }
10295            }
10296            assert!(
10297                Instant::now() < deadline,
10298                "a running module must report its pid after exec confirmation: {status:?}"
10299            );
10300            sleep(Duration::from_millis(10)).await;
10301        };
10302
10303        rustix::process::kill_process(
10304            rustix::process::Pid::from_raw(i32::try_from(first_pid).unwrap()).unwrap(),
10305            rustix::process::Signal::TERM,
10306        )
10307        .unwrap();
10308
10309        let deadline = Instant::now() + Duration::from_secs(10);
10310        let respawned = loop {
10311            let status = module.status().unwrap();
10312            if status.state == ModuleState::Running
10313                && status.pid.is_some_and(|pid| pid != first_pid)
10314            {
10315                break status;
10316            }
10317            assert!(
10318                Instant::now() < deadline,
10319                "protocol-none module was not respawned after an unrequested clean exit: {status:?}"
10320            );
10321            sleep(Duration::from_millis(10)).await;
10322        };
10323        assert_eq!(respawned.spawn_generation, 2);
10324        assert!(
10325            marker.exists(),
10326            "the child must have exited through its SIGTERM handler (exit 0), or this proves nothing about clean exits"
10327        );
10328
10329        let history = module.terminal_history();
10330        assert_eq!(history.entries.len(), 1, "{history:?}");
10331        let entry = &history.entries[0];
10332        assert_eq!(entry.exit_code, Some(0));
10333        assert_eq!(entry.exit_kind, subc_control::TerminalExitKind::Clean);
10334        assert_eq!(entry.disposition, TerminalDisposition::Restarting);
10335
10336        module.stop().await.unwrap();
10337    }
10338
10339    /// Repeated unrequested clean exits of a protocol-none module spend the
10340    /// restart budget exactly as crashes do, and the module ends `failed` with
10341    /// the budget named.
10342    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10343    async fn protocol_none_repeated_clean_exits_exhaust_the_restart_budget() {
10344        let supervisor = Supervisor::new_for_test(
10345            Arc::new(Registry::default()),
10346            RestartPolicy::new(1, Duration::ZERO),
10347        );
10348        let module = supervisor
10349            .spawn(ModuleSpec {
10350                module_id: "none-clean-exit-budget".to_string(),
10351                program: fake_aft_stub_path(),
10352                args: Vec::new(),
10353                env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "0".to_string())],
10354                reserved: false,
10355                reserved_prefixes: Vec::new(),
10356                protocol: ModuleProtocol::None,
10357                overlap: Default::default(),
10358            })
10359            .unwrap();
10360
10361        // Failed follows the terminal write; this deadline only bounds a hang,
10362        // not an assumed duration for the two launches or their exit recording.
10363        let deadline = Instant::now() + Duration::from_secs(10);
10364        loop {
10365            let status = module.status().unwrap();
10366            if status.state == ModuleState::Failed {
10367                break;
10368            }
10369            assert!(
10370                Instant::now() < deadline,
10371                "module never exhausted its budget: {status:?} {:?}",
10372                module.terminal_history()
10373            );
10374            sleep(Duration::from_millis(10)).await;
10375        }
10376        let history = module.terminal_history();
10377        assert_eq!(
10378            history
10379                .entries
10380                .iter()
10381                .map(|entry| (entry.exit_code, entry.disposition.clone()))
10382                .collect::<Vec<_>>(),
10383            vec![
10384                (Some(0), TerminalDisposition::Restarting),
10385                (Some(0), TerminalDisposition::Failed),
10386            ]
10387        );
10388        let detail = history.entries[1]
10389            .disposition_detail
10390            .as_deref()
10391            .expect("a budget failure names the budget");
10392        assert!(detail.contains("max_restarts=1"), "{detail}");
10393        assert_eq!(module.status().unwrap().spawn_generation, 2);
10394    }
10395
10396    #[test]
10397    fn restart_budget_failure_is_published_after_its_terminal_record() {
10398        let supervisor = Supervisor::new_for_test(
10399            Arc::new(Registry::default()),
10400            RestartPolicy::new(0, Duration::ZERO),
10401        );
10402        let runtime = supervisor.runtime_config();
10403        let spec = ModuleSpec {
10404            protocol: ModuleProtocol::None,
10405            ..windowed_crash_spec("budget-publication-order")
10406        };
10407        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(
10408            ModuleState::Running,
10409            true,
10410        )));
10411        let reaped_snapshot = snapshot.clone();
10412        let events = runtime.spawn_events.clone();
10413        let history = runtime.terminal_ring.clone();
10414
10415        // Exit recording takes the event-feed lock before the history lock.
10416        // Holding it pauses the writer after choosing a disposition but before
10417        // recording history, without assuming anything about scheduler timing.
10418        let before_record = events.0.lock().unwrap();
10419        let reap = std::thread::spawn(move || {
10420            tokio::runtime::Builder::new_current_thread()
10421                .enable_all()
10422                .build()
10423                .unwrap()
10424                .block_on(on_child_exit(
10425                    &spec,
10426                    runtime.restart_policy,
10427                    &supervisor.registry,
10428                    &reaped_snapshot,
10429                    &runtime.terminal_ring,
10430                    &runtime.spawn_events,
10431                    &runtime.child_roster,
10432                    ExitReport {
10433                        kind: ExitKind::Clean,
10434                        code: Some(0),
10435                        signal: None,
10436                        at_ms: 1,
10437                    },
10438                ))
10439        });
10440        // The deadline bounds a hung writer only; last_exit is the handshake.
10441        let deadline = Instant::now() + Duration::from_secs(10);
10442        let before_state = loop {
10443            let state = lock_snapshot(&snapshot).unwrap();
10444            if state.last_exit.is_some() {
10445                break state.state;
10446            }
10447            drop(state);
10448            assert!(Instant::now() < deadline, "exit decision was not reached");
10449            std::thread::yield_now();
10450        };
10451        let before_history = history.lock().unwrap().snapshot();
10452        drop(before_record);
10453        assert!(matches!(reap.join().unwrap(), NextAction::Stop { .. }));
10454        assert!(before_history.entries.is_empty());
10455        assert_ne!(
10456            before_state,
10457            ModuleState::Failed,
10458            "Failed was visible before its terminal record could be written"
10459        );
10460        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
10461        let history = history.lock().unwrap().snapshot();
10462        assert_eq!(history.entries.len(), 1);
10463        assert_eq!(history.entries[0].exit_code, Some(0));
10464        assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
10465    }
10466
10467    #[tokio::test]
10468    async fn restart_budget_failure_remains_failed_when_journal_append_fails() {
10469        let dir = cortexkit_test_support::ScratchDir::new("budget-journal-failure");
10470        let path = dir.join("terminals.jsonl");
10471        std::fs::create_dir(&path).unwrap();
10472        let supervisor = Supervisor::new_for_test(
10473            Arc::new(Registry::default()),
10474            RestartPolicy::new(0, Duration::ZERO),
10475        )
10476        .with_terminal_journal(path, "budget-journal-failure".into());
10477        let runtime = supervisor.runtime_config();
10478        let spec = windowed_crash_spec("budget-journal-failure");
10479        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
10480        assert!(matches!(
10481            on_child_exit(
10482                &spec,
10483                runtime.restart_policy,
10484                &supervisor.registry,
10485                &snapshot,
10486                &runtime.terminal_ring,
10487                &runtime.spawn_events,
10488                &runtime.child_roster,
10489                crash_exit_report(1),
10490            )
10491            .await,
10492            NextAction::Stop { .. }
10493        ));
10494        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
10495        let history = runtime
10496            .terminal_ring
10497            .lock()
10498            .unwrap()
10499            .durable_history(&spec.module_id);
10500        assert!(history.journal_write_failures > 0);
10501        assert_eq!(history.entries.len(), 1);
10502        assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
10503    }
10504
10505    #[test]
10506    fn restart_budget_failure_remains_failed_when_exit_recording_panics() {
10507        let supervisor = Supervisor::new_for_test(
10508            Arc::new(Registry::default()),
10509            RestartPolicy::new(0, Duration::ZERO),
10510        );
10511        let runtime = supervisor.runtime_config();
10512        let spec = windowed_crash_spec("budget-recording-panic");
10513        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
10514        runtime.spawn_events.emit_spawned(&spec.module_id, 1, 1);
10515        // Exhausting the event sequence makes emit_exited panic before the
10516        // terminal write, exercising publication on the recording unwind.
10517        runtime.spawn_events.0.lock().unwrap().seq = u64::MAX;
10518        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
10519            tokio::runtime::Builder::new_current_thread()
10520                .enable_all()
10521                .build()
10522                .unwrap()
10523                .block_on(on_child_exit(
10524                    &spec,
10525                    runtime.restart_policy,
10526                    &supervisor.registry,
10527                    &snapshot,
10528                    &runtime.terminal_ring,
10529                    &runtime.spawn_events,
10530                    &runtime.child_roster,
10531                    crash_exit_report(1),
10532                ))
10533        }));
10534        let panic = result.err().expect("recording must still unwind");
10535        assert_eq!(
10536            panic.downcast_ref::<String>().map(String::as_str),
10537            Some("spawn event sequence exhausted")
10538        );
10539        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
10540    }
10541
10542    /// A stop the supervisor itself requests still stops a protocol-none
10543    /// module, even though the child answers the SIGTERM with exit 0.
10544    #[cfg(unix)]
10545    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10546    async fn protocol_none_requested_stop_and_disable_do_not_respawn() {
10547        for disable in [false, true] {
10548            let label = if disable {
10549                "none-requested-disable"
10550            } else {
10551                "none-requested-stop"
10552            };
10553            let dir = cortexkit_test_support::ScratchDir::new(label);
10554            let (spec, ready, marker) = protocol_none_sigterm_exits_clean_spec(label, dir.path());
10555            let supervisor = Supervisor::new_for_test(
10556                Arc::new(Registry::default()),
10557                RestartPolicy::new(3, Duration::ZERO),
10558            );
10559            let module = supervisor.spawn(spec).unwrap();
10560            wait_for_file(&ready).await;
10561
10562            if disable {
10563                module.set_enabled(false).await.unwrap();
10564            } else {
10565                module.stop().await.unwrap();
10566            }
10567            assert!(
10568                marker.exists(),
10569                "{label}: the child must have left through its SIGTERM handler with exit 0"
10570            );
10571
10572            // Long enough for a zero-backoff respawn to have happened if the
10573            // exit had been treated as a crash.
10574            sleep(Duration::from_millis(500)).await;
10575            let status = module.status().unwrap();
10576            let expected = if disable {
10577                ModuleState::Disabled
10578            } else {
10579                ModuleState::Stopped
10580            };
10581            assert_eq!(status.state, expected, "{label}");
10582            assert_eq!(
10583                status.spawn_generation, 1,
10584                "{label}: respawned after a requested stop"
10585            );
10586            let history = module.terminal_history();
10587            assert_eq!(history.entries.len(), 1, "{label}: {history:?}");
10588            assert_eq!(history.entries[0].exit_code, Some(0), "{label}");
10589            assert_ne!(
10590                history.entries[0].disposition,
10591                TerminalDisposition::Restarting,
10592                "{label}"
10593            );
10594        }
10595    }
10596
10597    /// A subc-wire module that exits 0 on its own is still a stop: the
10598    /// protocol-none rule must not reach it.
10599    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10600    async fn subc_wire_clean_exit_is_still_a_stop() {
10601        let supervisor = Supervisor::new_for_test(
10602            Arc::new(Registry::default()),
10603            RestartPolicy::new(3, Duration::ZERO),
10604        );
10605        let module = supervisor
10606            .spawn(ModuleSpec {
10607                module_id: "wire-clean-exit".to_string(),
10608                program: fake_aft_stub_path(),
10609                args: Vec::new(),
10610                env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "0".to_string())],
10611                reserved: false,
10612                reserved_prefixes: Vec::new(),
10613                protocol: ModuleProtocol::Subc,
10614                overlap: Default::default(),
10615            })
10616            .unwrap();
10617
10618        let deadline = Instant::now() + Duration::from_secs(10);
10619        while module.terminal_history().entries.is_empty() {
10620            assert!(Instant::now() < deadline, "module never exited");
10621            sleep(Duration::from_millis(10)).await;
10622        }
10623        // Long enough for a zero-backoff respawn to have happened.
10624        sleep(Duration::from_millis(500)).await;
10625        let status = module.status().unwrap();
10626        assert_eq!(status.state, ModuleState::Stopped);
10627        assert_eq!(status.spawn_generation, 1);
10628        let history = module.terminal_history();
10629        assert_eq!(history.entries.len(), 1, "{history:?}");
10630        assert_eq!(history.entries[0].exit_code, Some(0));
10631        assert_eq!(history.entries[0].disposition, TerminalDisposition::Stopped);
10632    }
10633
10634    #[cfg(unix)]
10635    #[tokio::test]
10636    async fn reload_exit_keeps_roster_until_terminal_is_recorded_during_shutdown() {
10637        let dir = cortexkit_test_support::ScratchDir::new("reload-roster-terminal-order");
10638        let record = dir.join("live-children.json");
10639        let supervisor = Supervisor::new_for_test(
10640            Arc::new(Registry::default()),
10641            RestartPolicy::new(0, Duration::ZERO),
10642        );
10643        let mut runtime = supervisor.runtime_config();
10644        runtime.child_roster.record_to(record.clone());
10645        let gate = Arc::new(super::ReloadExitRecordGate::default());
10646        runtime.test_reload_exit_record_gate = Some(Arc::clone(&gate));
10647        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
10648        let spec = ModuleSpec {
10649            module_id: "reload-exit-roster".into(),
10650            program: fake_aft_stub_path(),
10651            args: Vec::new(),
10652            env: vec![("FAKE_AFT_EXIT_CODE".into(), "23".into())],
10653            reserved: false,
10654            reserved_prefixes: Vec::new(),
10655            protocol: ModuleProtocol::Subc,
10656            overlap: Default::default(),
10657        };
10658        let mut child = None;
10659        let reload = super::finish_reload_child(
10660            &spec,
10661            &runtime,
10662            &supervisor.registry,
10663            &supervisor.process_liveness,
10664            &snapshot,
10665            &mut child,
10666        );
10667        tokio::pin!(reload);
10668        tokio::select! {
10669            result = &mut reload => panic!("reload missed the before-record gate: {result:?}"),
10670            _ = gate.reached.notified() => {}
10671        }
10672        assert!(runtime
10673            .terminal_ring
10674            .lock()
10675            .unwrap()
10676            .snapshot()
10677            .entries
10678            .is_empty());
10679        assert_eq!(
10680            crate::live_children::read_record(&record).unwrap().len(),
10681            1,
10682            "shutdown must still wait for the reaped child until its terminal record exists"
10683        );
10684        runtime.child_roster.close();
10685        gate.resume.notify_one();
10686        assert!(reload.await.is_err());
10687        assert!(crate::live_children::read_record(&record)
10688            .unwrap()
10689            .is_empty());
10690        let history = runtime.terminal_ring.lock().unwrap().snapshot();
10691        assert_eq!(history.entries.len(), 1);
10692        assert_eq!(
10693            history.entries[0].disposition,
10694            TerminalDisposition::DaemonShutdown
10695        );
10696    }
10697
10698    /// Each restart-producing arm has its own state transition. Keeping their
10699    /// lifetime count assertions adjacent prevents a later new arm from silently
10700    /// spending budget without recording the historical restart.
10701    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10702    async fn every_restart_increment_path_advances_lifetime_count() {
10703        let supervisor = Supervisor::new_for_test(
10704            Arc::new(Registry::default()),
10705            RestartPolicy::new(1, Duration::ZERO),
10706        );
10707        let runtime = supervisor.runtime_config();
10708        let spec = ModuleSpec {
10709            module_id: "lifetime-increment-path".to_string(),
10710            program: PathBuf::from("/unused/lifetime-increment-path"),
10711            args: Vec::new(),
10712            env: Vec::new(),
10713            reserved: false,
10714            reserved_prefixes: Vec::new(),
10715            protocol: ModuleProtocol::Subc,
10716            overlap: Default::default(),
10717        };
10718
10719        let crash_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
10720        assert!(matches!(
10721            on_child_exit(
10722                &spec,
10723                runtime.restart_policy,
10724                &supervisor.registry,
10725                &crash_snapshot,
10726                &runtime.terminal_ring,
10727                &runtime.spawn_events,
10728                &runtime.child_roster,
10729                ExitReport {
10730                    kind: ExitKind::Crash,
10731                    code: Some(1),
10732                    signal: None,
10733                    at_ms: 1,
10734                },
10735            )
10736            .await,
10737            NextAction::Restart { schedule: _ }
10738        ));
10739        let (crash_restarts, crash_lifetime) = {
10740            let state = lock_snapshot(&crash_snapshot).unwrap();
10741            (state.crash_restarts.len(), state.lifetime_restarts)
10742        };
10743        assert_eq!(crash_restarts, 1);
10744        assert_eq!(crash_lifetime, 1);
10745
10746        let health_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
10747        let mut health_child = None;
10748        assert!(matches!(
10749            health_restart_child(
10750                &spec,
10751                &runtime,
10752                &supervisor.registry,
10753                &supervisor.process_liveness,
10754                &health_snapshot,
10755                &mut health_child,
10756                SupervisorHealthStatus::Failing,
10757                None,
10758                2,
10759            )
10760            .await,
10761            Ok(())
10762        ));
10763        assert!(health_child.is_none());
10764        assert!(lock_snapshot(&health_snapshot).unwrap().respawn_pending);
10765        assert!(runtime.scheduled_respawn.lock().unwrap().take().is_some());
10766        let (health_restarts, health_lifetime) = {
10767            let state = lock_snapshot(&health_snapshot).unwrap();
10768            (state.crash_restarts.len(), state.lifetime_restarts)
10769        };
10770        assert_eq!(health_restarts, 1);
10771        assert_eq!(health_lifetime, 1);
10772
10773        let reload_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
10774        let mut reload_child = None;
10775        assert!(matches!(
10776            handle_reload_spawn_failure(
10777                &spec,
10778                &runtime,
10779                &supervisor.process_liveness,
10780                &reload_snapshot,
10781                &mut reload_child,
10782                "forced reload spawn failure".to_string(),
10783            )
10784            .await,
10785            Err(SuperviseError::ReloadFailed { .. })
10786        ));
10787        let (reload_restarts, reload_lifetime) = {
10788            let state = lock_snapshot(&reload_snapshot).unwrap();
10789            (state.crash_restarts.len(), state.lifetime_restarts)
10790        };
10791        assert_eq!(reload_restarts, 1);
10792        assert_eq!(reload_lifetime, 1);
10793    }
10794
10795    #[tokio::test]
10796    async fn deliberately_severed_live_child_records_lifetime_without_spending_restart_budget() {
10797        let supervisor = Supervisor::new_for_test(
10798            Arc::new(Registry::default()),
10799            RestartPolicy::new(3, Duration::ZERO),
10800        );
10801        let runtime = supervisor.runtime_config();
10802        let spec = ModuleSpec {
10803            module_id: "deliberately-severed".to_string(),
10804            program: PathBuf::from("/unused/deliberately-severed"),
10805            args: Vec::new(),
10806            env: Vec::new(),
10807            reserved: false,
10808            reserved_prefixes: Vec::new(),
10809            protocol: ModuleProtocol::Subc,
10810            overlap: Default::default(),
10811        };
10812        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
10813        let process = ProcessIdentity {
10814            pid: 41,
10815            start_time: 101,
10816        };
10817        record_deliberate_severance(&snapshot, process).unwrap();
10818        let exit_report = apply_deliberate_severance_marker(
10819            &snapshot,
10820            Some(process),
10821            ExitReport {
10822                kind: ExitKind::Crash,
10823                code: Some(1),
10824                signal: None,
10825                at_ms: 1,
10826            },
10827        );
10828        assert_eq!(exit_report.kind, ExitKind::DeliberateSeverance);
10829
10830        assert!(matches!(
10831            on_child_exit(
10832                &spec,
10833                runtime.restart_policy,
10834                &supervisor.registry,
10835                &snapshot,
10836                &runtime.terminal_ring,
10837                &runtime.spawn_events,
10838                &runtime.child_roster,
10839                exit_report,
10840            )
10841            .await,
10842            NextAction::Restart { schedule: _ }
10843        ));
10844        let state = lock_snapshot(&snapshot).unwrap();
10845        assert_eq!(state.lifetime_restarts, 1);
10846        assert_eq!(state.crash_restarts.len(), 0);
10847    }
10848
10849    #[tokio::test]
10850    async fn genuine_crash_spends_restart_budget_and_records_lifetime() {
10851        let supervisor = Supervisor::new_for_test(
10852            Arc::new(Registry::default()),
10853            RestartPolicy::new(3, Duration::ZERO),
10854        );
10855        let runtime = supervisor.runtime_config();
10856        let spec = ModuleSpec {
10857            module_id: "genuine-crash".to_string(),
10858            program: PathBuf::from("/unused/genuine-crash"),
10859            args: Vec::new(),
10860            env: Vec::new(),
10861            reserved: false,
10862            reserved_prefixes: Vec::new(),
10863            protocol: ModuleProtocol::Subc,
10864            overlap: Default::default(),
10865        };
10866        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
10867
10868        assert!(matches!(
10869            on_child_exit(
10870                &spec,
10871                runtime.restart_policy,
10872                &supervisor.registry,
10873                &snapshot,
10874                &runtime.terminal_ring,
10875                &runtime.spawn_events,
10876                &runtime.child_roster,
10877                ExitReport {
10878                    kind: ExitKind::Crash,
10879                    code: Some(1),
10880                    signal: None,
10881                    at_ms: 1,
10882                },
10883            )
10884            .await,
10885            NextAction::Restart { schedule: _ }
10886        ));
10887        let state = lock_snapshot(&snapshot).unwrap();
10888        assert_eq!(state.lifetime_restarts, 1);
10889        assert_eq!(state.crash_restarts.len(), 1);
10890    }
10891
10892    fn crash_exit_report(at_ms: u64) -> ExitReport {
10893        ExitReport {
10894            kind: ExitKind::Crash,
10895            code: Some(1),
10896            signal: None,
10897            at_ms,
10898        }
10899    }
10900
10901    fn windowed_crash_spec(module_id: &str) -> ModuleSpec {
10902        ModuleSpec {
10903            module_id: module_id.to_string(),
10904            program: PathBuf::from("/unused").join(module_id),
10905            args: Vec::new(),
10906            env: Vec::new(),
10907            reserved: false,
10908            reserved_prefixes: Vec::new(),
10909            protocol: ModuleProtocol::Subc,
10910            overlap: Default::default(),
10911        }
10912    }
10913
10914    /// A real crash loop still stops. Three crashes with nothing aging out spend
10915    /// a budget of two and the third respawn is refused, and both surfaces an
10916    /// operator has -- the log line and the retained terminal record -- name the
10917    /// window rather than only the cap, because `max_restarts=2` alone is what
10918    /// this budget used to mean.
10919    #[tokio::test]
10920    async fn three_crashes_inside_the_window_stop_the_module_and_name_the_window() {
10921        let (logs, _guard) = crate::router::test_log::log_capture(tracing::Level::ERROR);
10922        let supervisor = Supervisor::new_for_test(
10923            Arc::new(Registry::default()),
10924            RestartPolicy::new(2, Duration::ZERO),
10925        );
10926        let runtime = supervisor.runtime_config();
10927        let spec = windowed_crash_spec("crash-loop-in-window");
10928        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
10929
10930        for attempt in 1..=2 {
10931            assert!(
10932                matches!(
10933                    on_child_exit(
10934                        &spec,
10935                        runtime.restart_policy,
10936                        &supervisor.registry,
10937                        &snapshot,
10938                        &runtime.terminal_ring,
10939                        &runtime.spawn_events,
10940                        &runtime.child_roster,
10941                        crash_exit_report(attempt),
10942                    )
10943                    .await,
10944                    NextAction::Restart { schedule: _ }
10945                ),
10946                "crash {attempt} is inside the budget and must respawn"
10947            );
10948        }
10949
10950        assert!(matches!(
10951            on_child_exit(
10952                &spec,
10953                runtime.restart_policy,
10954                &supervisor.registry,
10955                &snapshot,
10956                &runtime.terminal_ring,
10957                &runtime.spawn_events,
10958                &runtime.child_roster,
10959                crash_exit_report(3),
10960            )
10961            .await,
10962            NextAction::Stop { .. }
10963        ));
10964
10965        {
10966            let state = lock_snapshot(&snapshot).unwrap();
10967            assert_eq!(state.state, ModuleState::Failed);
10968            assert_eq!(state.crash_restarts.len(), 2);
10969            assert_eq!(state.lifetime_restarts, 2);
10970        }
10971
10972        let history = runtime
10973            .terminal_ring
10974            .lock()
10975            .expect("terminal ring is not poisoned")
10976            .snapshot();
10977        let last = history
10978            .entries
10979            .last()
10980            .expect("the refused crash is retained");
10981        assert_eq!(last.disposition, TerminalDisposition::Failed);
10982        assert_eq!(
10983            last.disposition_detail.as_deref(),
10984            Some("crash budget exhausted: max_restarts=2 within window_secs=600")
10985        );
10986
10987        let captured = crate::router::test_log::captured_logs(&logs);
10988        assert!(
10989            captured.contains("crash budget exhausted: max_restarts=2 within window_secs=600"),
10990            "the stop must be logged with its window: {captured}"
10991        );
10992    }
10993
10994    /// The rate, stated as a test: three crashes where the first has aged past
10995    /// the window are two crashes as far as the budget is concerned, so the
10996    /// third respawn is allowed and the ring holds only the two recent ones.
10997    ///
10998    /// This is the case a lifetime counter got wrong -- and the case the daemon
10999    /// now hits routinely, since a module exits non-zero every time its
11000    /// connection to the daemon drops.
11001    #[tokio::test]
11002    async fn a_crash_older_than_the_window_frees_its_slot_for_a_later_crash() {
11003        let supervisor = Supervisor::new_for_test(
11004            Arc::new(Registry::default()),
11005            RestartPolicy::new(2, Duration::ZERO),
11006        );
11007        let runtime = supervisor.runtime_config();
11008        let spec = windowed_crash_spec("crash-across-windows");
11009        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11010
11011        for attempt in 1..=2 {
11012            assert!(matches!(
11013                on_child_exit(
11014                    &spec,
11015                    runtime.restart_policy,
11016                    &supervisor.registry,
11017                    &snapshot,
11018                    &runtime.terminal_ring,
11019                    &runtime.spawn_events,
11020                    &runtime.child_roster,
11021                    crash_exit_report(attempt),
11022                )
11023                .await,
11024                NextAction::Restart { schedule: _ }
11025            ));
11026        }
11027
11028        // The oldest crash moves out of the window; nothing else about the
11029        // module changes.
11030        update_snapshot(&snapshot, Some(&spec.module_id), |state| {
11031            age_oldest_crash_restart_out_of_window(state, runtime.restart_policy.window);
11032        })
11033        .unwrap();
11034
11035        assert!(
11036            matches!(
11037                on_child_exit(
11038                    &spec,
11039                    runtime.restart_policy,
11040                    &supervisor.registry,
11041                    &snapshot,
11042                    &runtime.terminal_ring,
11043                    &runtime.spawn_events,
11044                    &runtime.child_roster,
11045                    crash_exit_report(3),
11046                )
11047                .await,
11048                NextAction::Restart { schedule: _ }
11049            ),
11050            "a crash older than the window must not hold a budget slot"
11051        );
11052
11053        let state = lock_snapshot(&snapshot).unwrap();
11054        assert_eq!(state.state, ModuleState::Restarting);
11055        assert_eq!(
11056            state.crash_restarts.len(),
11057            2,
11058            "the aged instant is dropped and the new one takes its place"
11059        );
11060        assert_eq!(
11061            state.lifetime_restarts, 3,
11062            "the ledger counts every restart, including the ones the window forgot"
11063        );
11064    }
11065
11066    /// An operator restart hands the budget back whole, and the ledger keeps
11067    /// counting. Those are different questions -- "how close is this module to
11068    /// being stopped" and "how many times has it been replaced" -- and the
11069    /// operator action answers only the first.
11070    #[tokio::test]
11071    async fn an_operator_restart_clears_the_ring_and_leaves_the_ledger_alone() {
11072        let supervisor = Supervisor::new_for_test(
11073            Arc::new(Registry::default()),
11074            RestartPolicy::new(2, Duration::ZERO),
11075        );
11076        let runtime = supervisor.runtime_config();
11077        let spec = windowed_crash_spec("operator-cleared-budget");
11078        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11079
11080        for attempt in 1..=2 {
11081            assert!(matches!(
11082                on_child_exit(
11083                    &spec,
11084                    runtime.restart_policy,
11085                    &supervisor.registry,
11086                    &snapshot,
11087                    &runtime.terminal_ring,
11088                    &runtime.spawn_events,
11089                    &runtime.child_roster,
11090                    crash_exit_report(attempt),
11091                )
11092                .await,
11093                NextAction::Restart { schedule: _ }
11094            ));
11095        }
11096
11097        reset_restart_count(&snapshot, &spec.module_id).unwrap();
11098        {
11099            let state = lock_snapshot(&snapshot).unwrap();
11100            assert!(
11101                state.crash_restarts.is_empty(),
11102                "an operator restart returns the full budget"
11103            );
11104            assert_eq!(
11105                state.lifetime_restarts, 2,
11106                "clearing the budget must not unmake the crashes"
11107            );
11108        }
11109
11110        assert!(
11111            matches!(
11112                on_child_exit(
11113                    &spec,
11114                    runtime.restart_policy,
11115                    &supervisor.registry,
11116                    &snapshot,
11117                    &runtime.terminal_ring,
11118                    &runtime.spawn_events,
11119                    &runtime.child_roster,
11120                    crash_exit_report(3),
11121                )
11122                .await,
11123                NextAction::Restart { schedule: _ }
11124            ),
11125            "the cleared budget must be spendable again"
11126        );
11127        let state = lock_snapshot(&snapshot).unwrap();
11128        assert_eq!(state.crash_restarts.len(), 1);
11129        assert_eq!(state.lifetime_restarts, 3);
11130    }
11131
11132    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11133    async fn severance_marker_for_a_dead_child_does_not_label_its_successor() {
11134        let severed = ProcessIdentity {
11135            pid: 41,
11136            start_time: 101,
11137        };
11138        let successor = ProcessIdentity {
11139            pid: 41,
11140            start_time: 202,
11141        };
11142        let module = module_with_recovery_snapshot(ModuleState::Running, true, 0);
11143        update_snapshot(&module.inner.snapshot, Some("recovery-snapshot"), |state| {
11144            state.pid = Some(successor.pid);
11145            state.process_start_time = Some(successor.start_time);
11146        })
11147        .unwrap();
11148        assert!(!module.record_deliberate_severance(severed).unwrap());
11149
11150        let exit_report = apply_deliberate_severance_marker(
11151            &module.inner.snapshot,
11152            Some(successor),
11153            ExitReport {
11154                kind: ExitKind::Crash,
11155                code: Some(1),
11156                signal: None,
11157                at_ms: 1,
11158            },
11159        );
11160
11161        assert_eq!(exit_report.kind, ExitKind::Crash);
11162    }
11163
11164    #[tokio::test]
11165    async fn drain_reap_marks_deliberate_severance_and_records_lifetime_without_budget() {
11166        let registry = Registry::default();
11167        let supervisor = Supervisor::new_for_test(
11168            Arc::new(Registry::default()),
11169            RestartPolicy::new(3, Duration::ZERO),
11170        );
11171        let runtime = supervisor.runtime_config();
11172        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11173        let spec = ModuleSpec {
11174            module_id: "drain-deliberate-severance".to_string(),
11175            program: fake_aft_stub_path(),
11176            args: Vec::new(),
11177            env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
11178            reserved: false,
11179            reserved_prefixes: Vec::new(),
11180            protocol: ModuleProtocol::Subc,
11181            overlap: Default::default(),
11182        };
11183        let mut child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
11184        let process = ProcessIdentity {
11185            pid: 41,
11186            start_time: 101,
11187        };
11188        child.process_identity = Some(process);
11189        update_snapshot(&snapshot, Some(&spec.module_id), |state| {
11190            state.pid = Some(process.pid);
11191            state.process_start_time = Some(process.start_time);
11192        })
11193        .unwrap();
11194        record_deliberate_severance(&snapshot, process).unwrap();
11195
11196        drain_child_to_state(
11197            &spec.module_id,
11198            spec.protocol,
11199            // The child exits on its own; no signal may change the exit this
11200            // test classifies.
11201            StopNotice::SentOverConnection,
11202            &registry,
11203            None,
11204            &snapshot,
11205            &runtime.terminal_ring,
11206            &runtime.spawn_events,
11207            child,
11208            Duration::from_secs(1),
11209            ModuleState::Stopped,
11210            Some(false),
11211        )
11212        .await
11213        .unwrap();
11214
11215        let state = lock_snapshot(&snapshot).unwrap();
11216        assert_eq!(
11217            state.last_exit.as_ref().map(|exit| exit.kind),
11218            Some(ExitKind::DeliberateSeverance)
11219        );
11220        assert_eq!(state.lifetime_restarts, 1);
11221        assert_eq!(state.crash_restarts.len(), 0);
11222        drop(state);
11223        let history = runtime.terminal_ring.lock().unwrap().snapshot();
11224        assert_eq!(
11225            history.entries[0].exit_kind,
11226            subc_control::TerminalExitKind::DeliberateSeverance
11227        );
11228    }
11229
11230    #[tokio::test]
11231    async fn ordinary_drain_reap_does_not_record_a_lifetime_restart() {
11232        let registry = Registry::default();
11233        let supervisor = Supervisor::new_for_test(
11234            Arc::new(Registry::default()),
11235            RestartPolicy::new(3, Duration::ZERO),
11236        );
11237        let runtime = supervisor.runtime_config();
11238        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11239        let spec = ModuleSpec {
11240            module_id: "ordinary-drain".to_string(),
11241            program: fake_aft_stub_path(),
11242            args: Vec::new(),
11243            env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
11244            reserved: false,
11245            reserved_prefixes: Vec::new(),
11246            protocol: ModuleProtocol::Subc,
11247            overlap: Default::default(),
11248        };
11249        let child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
11250
11251        drain_child_to_state(
11252            &spec.module_id,
11253            spec.protocol,
11254            // The child exits on its own; no signal may change the exit this
11255            // test classifies.
11256            StopNotice::SentOverConnection,
11257            &registry,
11258            None,
11259            &snapshot,
11260            &runtime.terminal_ring,
11261            &runtime.spawn_events,
11262            child,
11263            Duration::from_secs(1),
11264            ModuleState::Stopped,
11265            Some(false),
11266        )
11267        .await
11268        .unwrap();
11269
11270        let state = lock_snapshot(&snapshot).unwrap();
11271        assert_eq!(
11272            state.last_exit.as_ref().map(|exit| exit.kind),
11273            Some(ExitKind::Crash)
11274        );
11275        assert_eq!(state.lifetime_restarts, 0);
11276        assert_eq!(state.crash_restarts.len(), 0);
11277    }
11278
11279    #[test]
11280    fn fatal_connection_teardown_cannot_arm_a_marker_for_a_surviving_process() {
11281        // The server's generic fatal-routing branch only knows that the
11282        // connection failed; it does not know that the daemon deliberately
11283        // initiated a process-killing severance. Keep this seam explicit so a
11284        // future connection error path cannot silently reintroduce the stale
11285        // exemption that mislabels a later genuine crash.
11286        assert!(!include_str!("server.rs")
11287            .contains("router.record_deliberate_connection_severance(ctx.connection_id)"));
11288    }
11289
11290    /// The `route.closed` `drained` value must be the quiescence wait's own
11291    /// measurement (`Ok`), never invented -- except on `Err`, where there is no
11292    /// measurement at all and `false` is the one honest constant. This is the exact
11293    /// logic `begin_forwarding_drain_with` now applies before sending `route.closed`
11294    /// on every return path, including the one that used to return early via `?`
11295    /// with `route.closing` already sent and no `route.closed` ever following.
11296    #[test]
11297    fn drained_after_quiescence_wait_passes_ok_through_and_forces_false_on_err() {
11298        assert!(drained_after_quiescence_wait(&Ok(true)));
11299        assert!(!drained_after_quiescence_wait(&Ok(false)));
11300        assert!(!drained_after_quiescence_wait(&Err(
11301            SuperviseError::StatePoisoned { module_id: None }
11302        )));
11303    }
11304
11305    /// `supervise_loop`'s `wait()`-error arm now calls `record_terminal` like every
11306    /// other exit path does, so a module whose child `wait()` itself errored (e.g.
11307    /// already reaped out-of-band) still leaves a terminal record rather than none
11308    /// at all. Triggering the real `wait()` I/O error from an integration test would
11309    /// need a genuine already-reaped-child race, which is OS-specific and not
11310    /// something this suite attempts elsewhere; this test instead verifies the
11311    /// record produced for that arm end-to-end through the real `TerminalRing`, and
11312    /// the call site itself is verified by inspection to sit in that exact arm.
11313    #[test]
11314    fn wait_error_exit_report_records_a_failed_terminal_with_no_code_or_signal() {
11315        let ring = Arc::new(Mutex::new(TerminalRing::new(
11316            TerminalRingConfig::default(),
11317            0,
11318        )));
11319        record_wait_error_terminal("wait-error", &ring, &super::SpawnEventFeed::default());
11320
11321        let snapshot = ring.lock().unwrap().snapshot();
11322        assert_eq!(snapshot.entries.len(), 1);
11323        let entry = &snapshot.entries[0];
11324        assert_eq!(entry.exit_code, None);
11325        assert_eq!(entry.exit_signal, None);
11326        assert_eq!(entry.disposition, TerminalDisposition::Failed);
11327    }
11328
11329    #[test]
11330    fn wait_error_exit_path_preserves_spawn_event_density() {
11331        let feed = super::SpawnEventFeed::default();
11332        feed.configure_incarnation("wait-error-density".to_string());
11333        feed.emit_spawned("wait-error", 41, 1);
11334        let ring = Arc::new(Mutex::new(TerminalRing::new(
11335            TerminalRingConfig::default(),
11336            0,
11337        )));
11338
11339        record_wait_error_terminal("wait-error", &ring, &feed);
11340        feed.emit_spawned("after-wait-error", 42, 2);
11341
11342        let state = feed.0.lock().unwrap();
11343        let sequences = state
11344            .events
11345            .iter()
11346            .map(|event| event.cursor.seq)
11347            .collect::<Vec<_>>();
11348        assert_eq!(sequences, vec![1, 2, 3]);
11349        assert_eq!(state.events[1].kind, SpawnEventKind::Exited);
11350        assert_eq!(state.events[1].exit_code, None);
11351        assert_eq!(state.events[1].exit_signal, None);
11352    }
11353
11354    /// Pins the report's `kind` too: the wait-error arm treats an unwaitable child
11355    /// as a crash (matching `fail_snapshot`'s `Failed` disposition for this arm),
11356    /// not a clean exit it never actually observed.
11357    #[test]
11358    fn wait_error_exit_report_is_classified_as_a_crash() {
11359        assert_eq!(wait_error_exit_report().kind, ExitKind::Crash);
11360    }
11361}
11362
11363#[cfg(all(test, unix))]
11364mod health_event_tests {
11365    use super::*;
11366    use cortexkit_test_support::ScratchDir;
11367    use subc_protocol::{manifest::Concurrency, session::ModuleControlResponse};
11368
11369    struct Actor {
11370        module: SupervisedModule,
11371        registry: Arc<Registry>,
11372        forwarding: Arc<ForwardingTable>,
11373        spec: ModuleSpec,
11374        health: HealthConfig,
11375        rx: mpsc::Receiver<crate::router::OutboundFrame>,
11376        _home: ScratchDir,
11377    }
11378
11379    async fn wait_for<T>(reason: &str, mut observe: impl FnMut() -> Option<T>) -> T {
11380        // OS exec and socket readiness are real I/O. Stay runnable while waiting
11381        // for an observed condition, rather than letting paused time auto-advance
11382        // deadlines or assuming a fixed number of yields finishes the work.
11383        let deadline = std::time::Instant::now() + Duration::from_secs(10);
11384        let tick = Instant::now();
11385        loop {
11386            if let Some(value) = observe() {
11387                assert_eq!(Instant::now(), tick, "{reason} must not wait for a timer");
11388                return value;
11389            }
11390            assert!(
11391                std::time::Instant::now() < deadline,
11392                "timed out waiting for {reason}"
11393            );
11394            tokio::task::yield_now().await;
11395        }
11396    }
11397
11398    impl Actor {
11399        async fn start(protocol: ModuleProtocol, cadence: Duration) -> Self {
11400            let home = ScratchDir::new("health-events");
11401            let registry = Arc::new(Registry::default());
11402            let forwarding = Arc::new(ForwardingTable::default());
11403            let health = HealthConfig {
11404                cadence,
11405                deadline: Duration::from_secs(3600),
11406                ..HealthConfig::default()
11407            };
11408            let supervisor =
11409                Supervisor::new_for_test(registry.clone(), RestartPolicy::new(3, Duration::ZERO))
11410                    .with_forwarding(forwarding.clone())
11411                    .with_health_config(health.clone());
11412            let spec = ModuleSpec {
11413                module_id: "event-health-child".into(),
11414                program: PathBuf::from("/bin/sh"),
11415                // The process stays alive but has no real bus peer. Registrations
11416                // below exercise the same registry writes as accepted HELLOs.
11417                args: vec!["-c".into(), "exec sleep 600".into()],
11418                env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
11419                    .into_iter()
11420                    .map(|key| (key.into(), home.path().display().to_string()))
11421                    .collect(),
11422                reserved: false,
11423                reserved_prefixes: vec![],
11424                protocol,
11425                overlap: Default::default(),
11426            };
11427            let module = supervisor.spawn(spec.clone()).unwrap();
11428            let (_, rx) = mpsc::channel(8);
11429            let actor = Self {
11430                module,
11431                registry,
11432                forwarding,
11433                spec,
11434                health,
11435                rx,
11436                _home: home,
11437            };
11438            actor
11439                .wait_for_select(|checkpoint| checkpoint.generation > 0)
11440                .await;
11441            actor
11442        }
11443
11444        fn turns(&self) -> u64 {
11445            lock_snapshot(&self.module.inner.snapshot)
11446                .unwrap()
11447                .actor_turns
11448        }
11449
11450        async fn wait_for_select(
11451            &self,
11452            expected: impl Fn(&ActorSelectCheckpoint) -> bool,
11453        ) -> ActorSelectCheckpoint {
11454            wait_for("actor select checkpoint", || {
11455                let state = lock_snapshot(&self.module.inner.snapshot).unwrap();
11456                state.actor_select.filter(|checkpoint| {
11457                    checkpoint.generation == state.spawn_generation
11458                        && state.state == ModuleState::Running
11459                        && state.process_alive
11460                        && state.reported_pid().is_some()
11461                        && expected(checkpoint)
11462                })
11463            })
11464            .await
11465        }
11466
11467        fn hello(&mut self, connection: u64, health: bool) {
11468            let manifest =
11469                subc_protocol::manifest::ModuleManifest::builder(&self.spec.module_id, "1.0.0")
11470                    .protocol_ver(subc_protocol::PROTOCOL_VERSION)
11471                    .build();
11472            let connection = ConnectionId::new(connection);
11473            let (tx, rx) = mpsc::channel(8);
11474            self.rx = rx;
11475            self.forwarding
11476                .register_module_connection(
11477                    connection,
11478                    self.spec.module_id.clone(),
11479                    subc_protocol::PROTOCOL_VERSION,
11480                    Concurrency::ModuleManaged,
11481                    FrameSink::new(tx),
11482                )
11483                .unwrap();
11484            self.registry
11485                .register_with_control_ops(
11486                    manifest,
11487                    subc_protocol::PROTOCOL_VERSION,
11488                    connection,
11489                    if health {
11490                        vec![MODULE_CONTROL_OP_HEALTH_CHECK.into()]
11491                    } else {
11492                        vec![]
11493                    },
11494                )
11495                .unwrap();
11496        }
11497
11498        async fn probe(&mut self) -> crate::router::OutboundFrame {
11499            let frame = wait_for("outbound health probe", || match self.rx.try_recv() {
11500                Ok(frame) => Some(frame),
11501                Err(mpsc::error::TryRecvError::Empty) => None,
11502                Err(mpsc::error::TryRecvError::Disconnected) => panic!("health peer disconnected"),
11503            })
11504            .await;
11505            let body: Value = serde_json::from_slice(&frame.body).unwrap();
11506            assert_eq!(body["op"], MODULE_CONTROL_OP_HEALTH_CHECK);
11507            frame
11508        }
11509
11510        fn no_probe(&mut self) {
11511            assert!(matches!(
11512                self.rx.try_recv(),
11513                Err(mpsc::error::TryRecvError::Empty)
11514            ));
11515        }
11516
11517        fn answer(&self, connection: u64, frame: crate::router::OutboundFrame) {
11518            self.forwarding
11519                .complete_module_control_rpc(
11520                    ConnectionId::new(connection),
11521                    frame.header.corr,
11522                    Some(MODULE_CONTROL_OP_HEALTH_CHECK),
11523                    ModuleControlRpcOutcome::Response(ModuleControlResponse::HealthCheck {
11524                        status: HealthStatus::Ok,
11525                        detail: None,
11526                        metrics: None,
11527                    }),
11528                )
11529                .unwrap();
11530        }
11531    }
11532
11533    #[tokio::test(start_paused = true)]
11534    async fn no_health_children_park_for_sixty_seconds() {
11535        let none = Actor::start(ModuleProtocol::None, Duration::from_secs(30)).await;
11536        let unregistered = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
11537        let mut unadvertised = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
11538        unadvertised.hello(1, false);
11539        // Include any one-off registration wake in the budget; receiving a
11540        // no-health HELLO must not turn a parked child into a polling child.
11541        // Advance in 10 ms steps so the old poll really executes ~6,000 turns;
11542        // a single 60 s jump would only observe one expired timer.
11543        for _ in 0..6000 {
11544            tokio::time::advance(Duration::from_millis(10)).await;
11545            tokio::task::yield_now().await;
11546        }
11547        for actor in [&none, &unregistered, &unadvertised] {
11548            assert!(
11549                actor.turns() <= 5,
11550                "no-health actor woke {} times",
11551                actor.turns()
11552            );
11553            assert_eq!(actor.module.state().unwrap(), ModuleState::Running);
11554        }
11555    }
11556
11557    #[tokio::test(start_paused = true)]
11558    async fn late_hello_starts_a_due_probe_in_the_notification_tick() {
11559        // With a zero cadence the first probe is due at once, so this measures
11560        // only how fast the registration notification starts health probing,
11561        // not the normal 30-33 s wait before a first probe (checked below).
11562        let mut actor = Actor::start(ModuleProtocol::Subc, Duration::ZERO).await;
11563        let tick = Instant::now();
11564        actor.hello(1, true);
11565        actor.probe().await;
11566        assert_eq!(Instant::now(), tick, "HELLO must not wait for a poll");
11567    }
11568
11569    #[tokio::test(start_paused = true)]
11570    async fn hello_keeps_the_jittered_cadence_and_catalog_updates_do_not_reset_it() {
11571        let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
11572        let hello_tick = Instant::now();
11573        actor.hello(1, true);
11574        let armed = actor
11575            .wait_for_select(|checkpoint| {
11576                checkpoint.registered_connection == Some(ConnectionId::new(1))
11577                    && checkpoint.next_probe_at.is_some()
11578            })
11579            .await;
11580        let delay = jittered_health_delay(&actor.spec.module_id, 0, actor.health.cadence);
11581        assert!((Duration::from_secs(30)..Duration::from_secs(33)).contains(&delay));
11582        let first_deadline = hello_tick + delay;
11583        assert_eq!(armed.next_probe_at, Some(first_deadline));
11584        tokio::time::advance(Duration::from_secs(29)).await;
11585        let turns = actor.turns();
11586        actor
11587            .registry
11588            .replace_catalog_for_connection(ConnectionId::new(1), vec![], None, Some(true))
11589            .unwrap();
11590        let updated = actor
11591            .wait_for_select(|checkpoint| checkpoint.turn > turns)
11592            .await;
11593        assert_eq!(
11594            updated.next_probe_at,
11595            Some(first_deadline),
11596            "catalog update must not reset cadence"
11597        );
11598        actor.no_probe();
11599        tokio::time::advance(first_deadline - Instant::now()).await;
11600        let frame = actor.probe().await;
11601        actor.answer(1, frame);
11602        let next = jittered_health_delay(&actor.spec.module_id, 1, actor.health.cadence);
11603        let next_deadline = Instant::now() + next;
11604        let rearmed = actor
11605            .wait_for_select(|checkpoint| {
11606                checkpoint
11607                    .next_probe_at
11608                    .is_some_and(|deadline| deadline > first_deadline)
11609            })
11610            .await;
11611        assert_eq!(rearmed.next_probe_at, Some(next_deadline));
11612        tokio::time::advance(next - Duration::from_nanos(1)).await;
11613        actor.no_probe();
11614        tokio::time::advance(Duration::from_nanos(1)).await;
11615        actor.probe().await;
11616    }
11617
11618    #[tokio::test(start_paused = true)]
11619    async fn disconnect_disarms_and_reconnect_rearms_health() {
11620        let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
11621        actor.hello(1, true);
11622        actor
11623            .wait_for_select(|checkpoint| {
11624                checkpoint.registered_connection == Some(ConnectionId::new(1))
11625                    && checkpoint.next_probe_at.is_some()
11626            })
11627            .await;
11628        let turns = actor.turns();
11629        let tick = Instant::now();
11630        actor
11631            .registry
11632            .deregister_connection(ConnectionId::new(1))
11633            .unwrap();
11634        let disarmed = actor
11635            .wait_for_select(|checkpoint| {
11636                checkpoint.turn > turns && checkpoint.registered_connection.is_none()
11637            })
11638            .await;
11639        assert_eq!(disarmed.next_probe_at, None);
11640        assert_eq!(disarmed.wake_after, None);
11641        assert_eq!(
11642            Instant::now(),
11643            tick,
11644            "disconnect must disarm in the notification tick"
11645        );
11646        tokio::time::advance(Duration::from_secs(60)).await;
11647        actor.no_probe();
11648        let reconnect_tick = Instant::now();
11649        actor.hello(2, true);
11650        let rearmed = actor
11651            .wait_for_select(|checkpoint| {
11652                checkpoint.registered_connection == Some(ConnectionId::new(2))
11653                    && checkpoint.next_probe_at.is_some()
11654            })
11655            .await;
11656        let delay = jittered_health_delay(&actor.spec.module_id, 0, actor.health.cadence);
11657        assert_eq!(rearmed.next_probe_at, Some(reconnect_tick + delay));
11658        tokio::time::advance(delay).await;
11659        actor.probe().await;
11660    }
11661
11662    #[tokio::test(start_paused = true)]
11663    async fn reregistration_without_health_disarms_the_old_schedule() {
11664        let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
11665        actor.hello(1, true);
11666        actor
11667            .wait_for_select(|checkpoint| {
11668                checkpoint.registered_connection == Some(ConnectionId::new(1))
11669                    && checkpoint.next_probe_at.is_some()
11670            })
11671            .await;
11672        actor
11673            .registry
11674            .deregister_connection(ConnectionId::new(1))
11675            .unwrap();
11676        actor.hello(2, false);
11677        let disarmed = actor
11678            .wait_for_select(|checkpoint| {
11679                checkpoint.registered_connection == Some(ConnectionId::new(2))
11680            })
11681            .await;
11682        assert_eq!(disarmed.next_probe_at, None);
11683        assert_eq!(disarmed.wake_after, None);
11684        tokio::time::advance(Duration::from_secs(60)).await;
11685        actor.no_probe();
11686    }
11687
11688    #[tokio::test(start_paused = true)]
11689    async fn restart_waits_for_its_own_hello_before_rearming_health() {
11690        let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
11691        actor.hello(1, true);
11692        let armed = actor
11693            .wait_for_select(|checkpoint| {
11694                checkpoint.registered_connection == Some(ConnectionId::new(1))
11695                    && checkpoint.next_probe_at.is_some()
11696            })
11697            .await;
11698        // Simulate the old process's connection closing before the restart
11699        // tears the process down. The replacement process has started but has
11700        // not sent its HELLO, so it is not registered.
11701        actor
11702            .registry
11703            .deregister_connection(ConnectionId::new(1))
11704            .unwrap();
11705        actor.module.restart(Some(0)).await.unwrap();
11706        let parked = actor
11707            .wait_for_select(|checkpoint| {
11708                checkpoint.generation > armed.generation
11709                    && checkpoint.registered_connection.is_none()
11710                    && checkpoint.next_probe_at.is_none()
11711            })
11712            .await;
11713        assert_eq!(parked.wake_after, None);
11714        actor.rx.close();
11715        // Drain the stop notice the restart queued for the old process's connection,
11716        // so it isn't mistaken for traffic to the replacement.
11717        while actor.rx.try_recv().is_ok() {}
11718        let turns = actor.turns();
11719        tokio::time::advance(Duration::from_secs(60)).await;
11720        let after = actor.turns();
11721        eprintln!(
11722            "restart park turns: before={turns} after={after} delta={}",
11723            after - turns
11724        );
11725        assert_eq!(after, turns, "replacement must park before HELLO");
11726        let hello_tick = Instant::now();
11727        actor.hello(2, true);
11728        let rearmed = actor
11729            .wait_for_select(|checkpoint| {
11730                checkpoint.registered_connection == Some(ConnectionId::new(2))
11731                    && checkpoint.next_probe_at.is_some()
11732            })
11733            .await;
11734        let delay = jittered_health_delay(&actor.spec.module_id, 0, actor.health.cadence);
11735        assert_eq!(rearmed.next_probe_at, Some(hello_tick + delay));
11736        tokio::time::advance(delay).await;
11737        actor.probe().await;
11738    }
11739
11740    #[tokio::test(start_paused = true)]
11741    async fn rescan_adds_and_removes_http_health_without_polling() {
11742        use tokio::{
11743            io::{AsyncReadExt, AsyncWriteExt},
11744            net::TcpListener,
11745        };
11746        let mut actor = Actor::start(ModuleProtocol::None, Duration::from_secs(30)).await;
11747        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
11748        actor.health.http = Some(format!("http://{}/health", listener.local_addr().unwrap()));
11749        // One millisecond separates successive probes without a polling delay.
11750        actor.health.cadence = Duration::from_millis(1);
11751        let (started_tx, mut started_rx) = mpsc::channel(8);
11752        let peer = tokio::spawn(async move {
11753            loop {
11754                let (mut socket, _) = listener.accept().await.unwrap();
11755                let mut request = [0; 1024];
11756                assert!(socket.read(&mut request).await.unwrap() > 0);
11757                started_tx.send(()).await.unwrap();
11758                socket
11759                    .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
11760                    .await
11761                    .unwrap();
11762            }
11763        });
11764        let tick = Instant::now();
11765        let turns = actor.turns();
11766        actor
11767            .module
11768            .update_configuration(actor.spec.clone(), actor.health.clone(), None)
11769            .await
11770            .unwrap();
11771        let armed = actor
11772            .wait_for_select(|checkpoint| {
11773                checkpoint.turn > turns && checkpoint.next_probe_at.is_some()
11774            })
11775            .await;
11776        assert_eq!(armed.next_probe_at, Some(tick + Duration::from_millis(1)));
11777        assert_eq!(Instant::now(), tick);
11778        tokio::time::advance(Duration::from_millis(1)).await;
11779        let mut started = false;
11780        wait_for("successful HTTP probe", || {
11781            started |= started_rx.try_recv().is_ok();
11782            (started && actor.module.status().unwrap().health.status == SupervisorHealthStatus::Ok)
11783                .then_some(())
11784        })
11785        .await;
11786        assert!(started, "added HTTP check must start at its first deadline");
11787        assert_eq!(
11788            actor.module.status().unwrap().health.status,
11789            SupervisorHealthStatus::Ok
11790        );
11791        assert_eq!(Instant::now(), tick + Duration::from_millis(1));
11792        actor.health.http = None;
11793        let turns = actor.turns();
11794        actor
11795            .module
11796            .update_configuration(actor.spec.clone(), actor.health.clone(), None)
11797            .await
11798            .unwrap();
11799        let parked = actor
11800            .wait_for_select(|checkpoint| {
11801                checkpoint.turn > turns && checkpoint.next_probe_at.is_none()
11802            })
11803            .await;
11804        assert_eq!(parked.wake_after, None);
11805        let turns = actor.turns();
11806        tokio::time::advance(Duration::from_secs(60)).await;
11807        assert_eq!(
11808            actor.turns(),
11809            turns,
11810            "removed HTTP check must park the actor"
11811        );
11812        assert!(
11813            started_rx.try_recv().is_err(),
11814            "removed HTTP check must stay disarmed"
11815        );
11816        assert_eq!(
11817            actor.module.status().unwrap().health,
11818            ModuleHealthStatus::default()
11819        );
11820        peer.abort();
11821    }
11822}
11823
11824#[cfg(test)]
11825mod health_evidence_tests {
11826    use super::{HealthProbeError, HealthProbeEvidence};
11827    use std::collections::HashSet;
11828
11829    /// The evidential asymmetry, asserted rather than described.
11830    ///
11831    /// Exactly ONE observation is proof a module cannot serve, and the one that
11832    /// fires under CPU starvation is not it. Before the split, all fifteen
11833    /// construction sites collapsed into a single String, so a timeout carried the
11834    /// same weight as a dead lane -- which is how a healthy module was restarted
11835    /// three times in one day.
11836    #[test]
11837    fn only_a_dead_lane_is_proof_of_death() {
11838        assert!(HealthProbeError::lane_dead("gone").is_proof_of_death());
11839        // Three non-proof classes, each for a different reason: silence is
11840        // consistent with health, a bad answer proves the module ALIVE, and a
11841        // daemon-side fault never reached the module at all.
11842        assert!(!HealthProbeError::no_answer("timed out").is_proof_of_death());
11843        assert!(!HealthProbeError::bad_answer("garbage").is_proof_of_death());
11844        assert!(!HealthProbeError::misconfigured("no table").is_proof_of_death());
11845    }
11846
11847    /// Labels must be distinct, or the operator-facing distinction is cosmetic.
11848    ///
11849    /// A shared label renders two different observations identically in the line an
11850    /// operator reads after an unexplained restart -- the exact confusion this
11851    /// change removes.
11852    #[test]
11853    fn every_evidence_class_has_a_distinct_label() {
11854        let labels = [
11855            HealthProbeError::lane_dead("").label(),
11856            HealthProbeError::no_answer("").label(),
11857            HealthProbeError::bad_answer("").label(),
11858            HealthProbeError::misconfigured("").label(),
11859        ];
11860        let unique: HashSet<_> = labels.iter().collect();
11861        assert_eq!(unique.len(), labels.len(), "labels collided: {labels:?}");
11862    }
11863
11864    /// The class is additional information, not a replacement.
11865    ///
11866    /// An operator needs both "this was silence" and the specific text saying how
11867    /// long we waited; a classification that swallowed the message would trade one
11868    /// missing distinction for another.
11869    #[test]
11870    fn classification_preserves_the_original_message() {
11871        let err = HealthProbeError::no_answer("module did not answer within 5s");
11872        assert_eq!(err.to_string(), "module did not answer within 5s");
11873        assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
11874    }
11875}
11876
11877#[cfg(test)]
11878mod health_tombstone_tests {
11879    use std::{path::PathBuf, sync::Arc, time::Duration};
11880
11881    use subc_protocol::{
11882        manifest::Concurrency,
11883        session::{HealthStatus, ModuleControlResponse},
11884    };
11885    use tokio::sync::mpsc;
11886
11887    use super::{
11888        probe_module_health, HealthAction, HealthConfig, HealthProbeEvidence, ModuleProtocol,
11889        ModuleSpec, RestartPolicy, Supervisor, SupervisorRuntimeConfig,
11890    };
11891    use crate::{
11892        control::ControlHandler,
11893        forwarding::{ForwardingTable, ModuleControlRpcCompletion, ModuleControlRpcOutcome},
11894        registry::{ConnectionId, Registry},
11895        router::FrameSink,
11896    };
11897
11898    struct ProbeHarness {
11899        spec: ModuleSpec,
11900        runtime: SupervisorRuntimeConfig,
11901        forwarding: Arc<ForwardingTable>,
11902        module_connection: ConnectionId,
11903        module_rx: mpsc::Receiver<crate::router::OutboundFrame>,
11904        handler: ControlHandler,
11905        module: super::SupervisedModule,
11906    }
11907
11908    fn probe_harness() -> ProbeHarness {
11909        let registry = Arc::new(Registry::default());
11910        let forwarding = Arc::new(ForwardingTable::default());
11911        let supervisor_handle = super::SupervisorHandle::new();
11912        let health = HealthConfig {
11913            http: None,
11914            cadence: Duration::from_secs(30),
11915            deadline: Duration::from_secs(5),
11916            failure_threshold: 3,
11917            on_degraded: HealthAction::Report,
11918            on_failing: HealthAction::Report,
11919            critical: false,
11920        };
11921        let supervisor = Supervisor::new_for_test(Arc::clone(&registry), RestartPolicy::default())
11922            .with_forwarding(Arc::clone(&forwarding))
11923            .with_handle(supervisor_handle.clone())
11924            .with_health_config(health);
11925        let spec = ModuleSpec {
11926            module_id: "late-health-module".to_string(),
11927            program: PathBuf::from("disabled-module"),
11928            args: Vec::new(),
11929            env: Vec::new(),
11930            reserved: false,
11931            reserved_prefixes: Vec::new(),
11932            protocol: ModuleProtocol::Subc,
11933            overlap: Default::default(),
11934        };
11935        let module = supervisor
11936            .supervise_configured(spec.clone(), false)
11937            .unwrap();
11938        let runtime = supervisor.runtime_config();
11939        let handler = ControlHandler::with_forwarding(registry, Arc::clone(&forwarding))
11940            .with_supervisor(supervisor_handle);
11941        let module_connection = ConnectionId::new(700);
11942        let (module_tx, module_rx) = mpsc::channel(8);
11943        forwarding
11944            .register_module_connection(
11945                module_connection,
11946                spec.module_id.clone(),
11947                subc_protocol::PROTOCOL_VERSION,
11948                Concurrency::ModuleManaged,
11949                FrameSink::new(module_tx),
11950            )
11951            .unwrap();
11952
11953        ProbeHarness {
11954            spec,
11955            runtime,
11956            forwarding,
11957            module_connection,
11958            module_rx,
11959            handler,
11960            module,
11961        }
11962    }
11963
11964    async fn finish_after(
11965        harness: &mut ProbeHarness,
11966        stall: Duration,
11967    ) -> ModuleControlRpcCompletion {
11968        assert!(stall > harness.runtime.health.deadline);
11969        let deadline = harness.runtime.health.deadline;
11970        let probe = probe_module_health(&harness.spec.module_id, &harness.runtime, None);
11971        let answer = async {
11972            let frame = harness.module_rx.recv().await.expect("health.check frame");
11973            tokio::time::advance(deadline).await;
11974            tokio::task::yield_now().await;
11975            tokio::time::advance(stall - deadline).await;
11976            harness
11977                .forwarding
11978                .complete_module_control_rpc(
11979                    harness.module_connection,
11980                    frame.header.corr,
11981                    Some("health.check"),
11982                    ModuleControlRpcOutcome::Response(ModuleControlResponse::HealthCheck {
11983                        status: HealthStatus::Ok,
11984                        detail: None,
11985                        metrics: None,
11986                    }),
11987                )
11988                .unwrap()
11989        };
11990        let (probe_result, completion) = tokio::join!(probe, answer);
11991        let err = probe_result.expect_err("probe must miss its deadline");
11992        assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
11993        completion
11994    }
11995
11996    async fn time_out_without_answer(harness: &mut ProbeHarness) {
11997        let deadline = harness.runtime.health.deadline;
11998        let probe = probe_module_health(&harness.spec.module_id, &harness.runtime, None);
11999        let exhaust_deadline = async {
12000            let _frame = harness.module_rx.recv().await.expect("health.check frame");
12001            tokio::time::advance(deadline).await;
12002            tokio::task::yield_now().await;
12003        };
12004        let (probe_result, ()) = tokio::join!(probe, exhaust_deadline);
12005        let err = probe_result.expect_err("probe must miss its deadline");
12006        assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
12007    }
12008
12009    async fn run_probe_cycle(harness: &mut ProbeHarness, answer: bool) {
12010        let registry = Arc::clone(&harness.module.inner.registry);
12011        let snapshot = Arc::clone(&harness.module.inner.snapshot);
12012        let process_liveness = super::SupervisorProcessLiveness::default();
12013        let mut child = None;
12014        let cycle = super::run_health_probe_cycle(
12015            &harness.spec,
12016            &harness.runtime,
12017            &registry,
12018            &process_liveness,
12019            &snapshot,
12020            &mut child,
12021        );
12022        let peer = async {
12023            let frame = harness.module_rx.recv().await.expect("health.check frame");
12024            if answer {
12025                harness
12026                    .forwarding
12027                    .complete_module_control_rpc(
12028                        harness.module_connection,
12029                        frame.header.corr,
12030                        Some("health.check"),
12031                        ModuleControlRpcOutcome::Response(ModuleControlResponse::HealthCheck {
12032                            status: HealthStatus::Ok,
12033                            detail: None,
12034                            metrics: Some(serde_json::json!({"ready": true})),
12035                        }),
12036                    )
12037                    .unwrap();
12038            } else {
12039                tokio::time::advance(harness.runtime.health.deadline).await;
12040                tokio::task::yield_now().await;
12041            }
12042        };
12043        tokio::join!(cycle, peer);
12044    }
12045
12046    #[tokio::test(start_paused = true)]
12047    async fn unanswered_probe_is_unknown_until_threshold_and_ok_report_recovers() {
12048        let mut harness = probe_harness();
12049        // Drive the probe cycle directly with an in-memory wire peer. Stop the
12050        // disabled module's monitor so only this test owns lifecycle transitions;
12051        // no OS process is launched, and a restart is observed at scheduling.
12052        let monitor = harness.module.inner.monitor.lock().unwrap().take().unwrap();
12053        monitor.abort();
12054        let _ = monitor.await;
12055        super::update_snapshot(&harness.module.inner.snapshot, None, |state| {
12056            state.enabled = true;
12057            state.state = super::ModuleState::Running;
12058            state.process_alive = true;
12059        })
12060        .unwrap();
12061
12062        run_probe_cycle(&mut harness, true).await;
12063        assert_eq!(
12064            harness.module.status().unwrap().health.status,
12065            super::SupervisorHealthStatus::Ok
12066        );
12067
12068        for failures in 1..harness.runtime.health.failure_threshold {
12069            run_probe_cycle(&mut harness, false).await;
12070            let status = harness.module.status().unwrap();
12071            assert_eq!(status.health.status, super::SupervisorHealthStatus::Unknown);
12072            assert_eq!(status.health.consecutive_failures, failures);
12073            assert!(status.health.last_probe_ms.is_some());
12074            assert!(status.health.detail.unwrap().starts_with("[no-answer]"));
12075            assert!(status.health.metrics.is_none());
12076            assert_eq!(status.state, super::ModuleState::Running);
12077            assert!(status.process_alive);
12078            assert_eq!(status.restart_count, 0);
12079            assert_eq!(status.lifetime_restarts, 0);
12080            assert!(status.health.last_action.is_none());
12081            assert!(harness.runtime.scheduled_respawn.lock().unwrap().is_none());
12082        }
12083
12084        run_probe_cycle(&mut harness, true).await;
12085        let recovered = harness.module.status().unwrap();
12086        assert_eq!(recovered.health.status, super::SupervisorHealthStatus::Ok);
12087        assert_eq!(recovered.health.consecutive_failures, 0);
12088        assert!(recovered.health.detail.is_none());
12089        assert_eq!(
12090            recovered.health.metrics,
12091            Some(serde_json::json!({"ready": true}))
12092        );
12093        assert_eq!(recovered.lifetime_restarts, 0);
12094
12095        for failures in 1..=harness.runtime.health.failure_threshold {
12096            run_probe_cycle(&mut harness, false).await;
12097            let status = harness.module.status().unwrap();
12098            assert_eq!(status.health.consecutive_failures, failures);
12099            if failures < harness.runtime.health.failure_threshold {
12100                assert_eq!(status.health.status, super::SupervisorHealthStatus::Unknown);
12101                assert_eq!(status.state, super::ModuleState::Running);
12102                assert_eq!(status.lifetime_restarts, 0);
12103                assert!(harness.runtime.scheduled_respawn.lock().unwrap().is_none());
12104            } else {
12105                assert_eq!(
12106                    status.health.status,
12107                    super::SupervisorHealthStatus::Unresponsive
12108                );
12109                assert_eq!(status.state, super::ModuleState::Restarting);
12110                assert_eq!(status.restart_count, 1);
12111                assert_eq!(status.lifetime_restarts, 1);
12112                assert_eq!(status.health.last_action.as_deref(), Some("restart"));
12113                assert!(harness.runtime.scheduled_respawn.lock().unwrap().is_some());
12114            }
12115        }
12116    }
12117
12118    #[tokio::test(start_paused = true)]
12119    async fn late_health_answers_record_start_anchored_latency_for_two_stalls() {
12120        let mut harness = probe_harness();
12121
12122        let first = finish_after(&mut harness, Duration::from_secs(8)).await;
12123        let first_latency = match &first {
12124            ModuleControlRpcCompletion::LateHealthAnswer { latency, .. } => *latency,
12125            other => panic!("late answer was not retained: {other:?}"),
12126        };
12127        assert!(harness.handler.observe_module_control_completion(first));
12128
12129        let second = finish_after(&mut harness, Duration::from_secs(11)).await;
12130        let second_latency = match &second {
12131            ModuleControlRpcCompletion::LateHealthAnswer { latency, .. } => *latency,
12132            other => panic!("late answer was not retained: {other:?}"),
12133        };
12134        assert!(harness.handler.observe_module_control_completion(second));
12135
12136        assert_eq!(first_latency, Duration::from_secs(8));
12137        assert_eq!(
12138            second_latency - first_latency,
12139            Duration::from_secs(3),
12140            "latency must grow linearly with the additional stall"
12141        );
12142        let health = harness.module.status().unwrap().health;
12143        assert_eq!(health.late_answer_count, 2);
12144        assert_eq!(health.last_late_answer_latency_ms, Some(11_000));
12145    }
12146
12147    /// A module that answers every probe late must never march to the kill
12148    /// threshold: the late answer proves it is alive, so it must clear the miss
12149    /// streak the timeout recorded. Without the reset, a CPU-starved module
12150    /// that serves every probe seconds past the deadline accumulates
12151    /// `consecutive_failures` to the threshold and is killed — the exact
12152    /// sequence from the 2026-08-14 aft disable, where the daemon logged
12153    /// "proves the module is alive" five times while counting five misses.
12154    #[tokio::test(start_paused = true)]
12155    async fn late_answer_clears_the_consecutive_failure_streak() {
12156        let mut harness = probe_harness();
12157
12158        // Timeout recorded first: the probe path saw no answer in time.
12159        time_out_without_answer(&mut harness).await;
12160        harness
12161            .module
12162            .record_health_probe_failure_for_test("[no-answer] test miss")
12163            .unwrap();
12164        assert_eq!(
12165            harness.module.status().unwrap().health.consecutive_failures,
12166            1,
12167            "precondition: the miss must be on the streak before the late answer"
12168        );
12169
12170        // The stalled reply then lands: proof of life.
12171        let late = finish_after(&mut harness, Duration::from_secs(9)).await;
12172        assert!(matches!(
12173            late,
12174            ModuleControlRpcCompletion::LateHealthAnswer { .. }
12175        ));
12176        assert!(harness.handler.observe_module_control_completion(late));
12177
12178        let health = harness.module.status().unwrap().health;
12179        assert_eq!(
12180            health.consecutive_failures, 0,
12181            "a late answer is an answer: the streak must reset"
12182        );
12183        assert_eq!(health.late_answer_count, 1);
12184    }
12185
12186    #[tokio::test(start_paused = true)]
12187    async fn repeated_serial_probe_cycles_keep_one_tombstone_per_endpoint() {
12188        let mut harness = probe_harness();
12189
12190        for _ in 0..20 {
12191            time_out_without_answer(&mut harness).await;
12192            assert_eq!(
12193                harness.forwarding.health_probe_tombstone_count().unwrap(),
12194                1
12195            );
12196        }
12197    }
12198}
12199
12200#[cfg(test)]
12201mod child_env_tests {
12202    use super::{
12203        apply_child_env, apply_spawn_role, apply_wire_spawn_args, ModuleProtocol, ModuleSpec,
12204        SpawnRole, SupervisorHandle, SPAWN_ROLE_SWAP_CANDIDATE, SUBC_ARG, SUBC_LAUNCH_NONCE_ENV,
12205        SUBC_MODULE_ID_ENV, SUBC_SPAWN_ROLE_ENV,
12206    };
12207    use std::{ffi::OsStr, path::PathBuf};
12208    use tokio::process::Command;
12209
12210    fn spec(env: Vec<(String, String)>) -> ModuleSpec {
12211        ModuleSpec {
12212            module_id: "env-plan".to_string(),
12213            program: PathBuf::from("/nonexistent"),
12214            args: Vec::new(),
12215            env,
12216            reserved: false,
12217            reserved_prefixes: Vec::new(),
12218            protocol: ModuleProtocol::Subc,
12219            overlap: Default::default(),
12220        }
12221    }
12222
12223    /// Ambient `CK_LOG` is REMOVED for an unconfigured module, and a configured
12224    /// one still gets its own.
12225    ///
12226    /// This is the narrow goal `env_clear()` was reached for, and the reason the
12227    /// fix is `env_remove` rather than deleting the line: an operator's ambient
12228    /// filter silently becoming an unconfigured module's log level is a real
12229    /// defect, just a much smaller one than clearing the environment.
12230    ///
12231    /// Asserted on the command plan rather than a spawned child because proving
12232    /// the ABSENCE of an inherited variable needs the parent's environment
12233    /// mutated, and `forbid(unsafe_code)` refuses that. `get_envs()` reports a
12234    /// removal as `(key, None)`, which is exactly the distinction wanted: not
12235    /// "absent because nobody set it" but "explicitly unset for the child".
12236    #[test]
12237    fn ambient_ck_log_is_removed_and_a_configured_one_survives() {
12238        let mut command = Command::new("/nonexistent");
12239        apply_child_env(&mut command, &spec(Vec::new()));
12240        let removed = command
12241            .as_std()
12242            .get_envs()
12243            .any(|(key, value)| key == OsStr::new("CK_LOG") && value.is_none());
12244        assert!(
12245            removed,
12246            "ambient CK_LOG must be explicitly removed for an unconfigured module"
12247        );
12248
12249        let mut configured = Command::new("/nonexistent");
12250        apply_child_env(
12251            &mut configured,
12252            &spec(vec![("CK_LOG".to_string(), "debug".to_string())]),
12253        );
12254        let effective = configured
12255            .as_std()
12256            .get_envs()
12257            .filter(|(key, _)| *key == OsStr::new("CK_LOG"))
12258            .last()
12259            .map(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()));
12260        assert_eq!(
12261            effective,
12262            Some(Some("debug".to_string())),
12263            "a module's configured CK_LOG must survive the ambient removal"
12264        );
12265    }
12266
12267    /// A `protocol: "none"` spawn carries NO `--subc` argument and NO launch
12268    /// nonce; a subc-wire spawn carries both. Asserted on the command plan for
12269    /// the same reason as the CK_LOG test above.
12270    ///
12271    /// The argument is the load-bearing half: a stock binary exits on an
12272    /// unknown flag before it listens, so with `--subc` appended the mode
12273    /// could not supervise the one process it exists for. Found by the first
12274    /// conformance run (nats-server: `flag provided but not defined: -subc`).
12275    #[test]
12276    fn protocol_none_spawn_carries_no_subc_argument_and_no_nonce() {
12277        let connection_file = std::path::Path::new("/run/subc-connection.json");
12278        let handle = SupervisorHandle::new();
12279
12280        let mut none_spec = spec(Vec::new());
12281        none_spec.protocol = ModuleProtocol::None;
12282        let mut none = Command::new("/nonexistent");
12283        let none_handoff =
12284            apply_wire_spawn_args(&mut none, &none_spec, Some(connection_file), Some(&handle))
12285                .expect("protocol-none spawn args apply");
12286        assert!(
12287            none_handoff.is_none(),
12288            "protocol:none spawn must not receive a nonce descriptor"
12289        );
12290        assert!(
12291            !none.as_std().get_envs().any(|(key, value)| key
12292                == OsStr::new(subc_os::LAUNCH_NONCE_FD_ENV)
12293                && value.is_some()),
12294            "protocol:none spawn must not name a nonce descriptor"
12295        );
12296        let none_args: Vec<String> = none
12297            .as_std()
12298            .get_args()
12299            .map(|a| a.to_string_lossy().into_owned())
12300            .collect();
12301        assert!(
12302            !none_args.iter().any(|a| a == SUBC_ARG),
12303            "protocol:none argv must not carry --subc; got {none_args:?}"
12304        );
12305        let none_has_nonce = none
12306            .as_std()
12307            .get_envs()
12308            .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some());
12309        assert!(
12310            !none_has_nonce,
12311            "protocol:none spawn must not receive a launch nonce"
12312        );
12313        let none_has_module_id = none
12314            .as_std()
12315            .get_envs()
12316            .any(|(key, value)| key == OsStr::new(SUBC_MODULE_ID_ENV) && value.is_some());
12317        assert!(
12318            none_has_module_id,
12319            "SUBC_MODULE_ID is inert and stays on every path"
12320        );
12321        assert!(
12322            handle.spawn_nonce(&none_spec.module_id).is_none(),
12323            "no nonce record for a process that will never present one"
12324        );
12325
12326        // Control: the subc-wire path is unchanged by the branch above.
12327        let wire_spec = spec(Vec::new());
12328        let mut wire = Command::new("/nonexistent");
12329        let wire_handoff =
12330            apply_wire_spawn_args(&mut wire, &wire_spec, Some(connection_file), Some(&handle))
12331                .expect("subc-wire spawn args apply");
12332        let wire_fd_env = wire
12333            .as_std()
12334            .get_envs()
12335            .find(|(key, _)| *key == OsStr::new(subc_os::LAUNCH_NONCE_FD_ENV))
12336            .and_then(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()));
12337        #[cfg(unix)]
12338        assert_eq!(
12339            wire_fd_env,
12340            Some(wire_handoff.expect("a descriptor handoff").fd_env_value()),
12341            "a subc-wire spawn names the pipe it will receive at descriptor 3"
12342        );
12343        #[cfg(not(unix))]
12344        assert!(wire_handoff.is_none() && wire_fd_env.is_none());
12345        let wire_args: Vec<String> = wire
12346            .as_std()
12347            .get_args()
12348            .map(|a| a.to_string_lossy().into_owned())
12349            .collect();
12350        assert_eq!(
12351            wire_args,
12352            vec![
12353                SUBC_ARG.to_string(),
12354                connection_file.to_string_lossy().into_owned()
12355            ],
12356            "a subc-wire spawn still carries --subc <path>"
12357        );
12358        assert_eq!(
12359            wire.as_std()
12360                .get_envs()
12361                .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some()),
12362            !cfg!(unix),
12363            "only Windows supplies the environment nonce"
12364        );
12365        assert!(handle.spawn_nonce(&wire_spec.module_id).is_some());
12366    }
12367
12368    /// A plain spawn EXPLICITLY REMOVES the spawn role, even when the module's
12369    /// spec tries to set it; only a swap candidate carries it.
12370    ///
12371    /// "Set it only on candidates" is not enough, because spawn applies the
12372    /// spec's env verbatim and the daemon's own environment is inherited: either
12373    /// could hand a plain restart the swap role, and a module reading it would
12374    /// warm on its long swap budget while callers wait. Asserted as an explicit
12375    /// removal (`(key, None)`), not mere absence, for the reason the `CK_LOG`
12376    /// test above gives.
12377    #[test]
12378    fn plain_spawn_removes_the_spawn_role_even_when_the_spec_sets_it() {
12379        let role = |command: &Command| {
12380            command
12381                .as_std()
12382                .get_envs()
12383                .filter(|(key, _)| *key == OsStr::new(SUBC_SPAWN_ROLE_ENV))
12384                .last()
12385                .map(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()))
12386        };
12387        let forged = spec(vec![(
12388            SUBC_SPAWN_ROLE_ENV.to_string(),
12389            SPAWN_ROLE_SWAP_CANDIDATE.to_string(),
12390        )]);
12391
12392        let mut plain = Command::new("/nonexistent");
12393        apply_child_env(&mut plain, &forged);
12394        apply_spawn_role(&mut plain, SpawnRole::Plain);
12395        assert_eq!(
12396            role(&plain),
12397            Some(None),
12398            "a plain spawn must remove SUBC_SPAWN_ROLE, whatever the spec says"
12399        );
12400
12401        let mut candidate = Command::new("/nonexistent");
12402        apply_child_env(&mut candidate, &spec(Vec::new()));
12403        apply_spawn_role(&mut candidate, SpawnRole::SwapCandidate);
12404        assert_eq!(
12405            role(&candidate),
12406            Some(Some(SPAWN_ROLE_SWAP_CANDIDATE.to_string()))
12407        );
12408    }
12409
12410    /// Daemon-private capture retention keys never reach the child.
12411    ///
12412    /// cortexkit-log exposes retention as a Rust struct with no environment
12413    /// names, so these entries are supervisor metadata. Passing them through
12414    /// would invent a public child-process contract by accident.
12415    #[test]
12416    fn daemon_private_capture_keys_are_not_passed_to_the_child() {
12417        let mut command = Command::new("/nonexistent");
12418        apply_child_env(
12419            &mut command,
12420            &spec(vec![
12421                (super::CAPTURE_KEEP_ENV.to_string(), "5".to_string()),
12422                ("KEPT".to_string(), "yes".to_string()),
12423            ]),
12424        );
12425        let keys: Vec<String> = command
12426            .as_std()
12427            .get_envs()
12428            .filter(|(_, value)| value.is_some())
12429            .map(|(key, _)| key.to_string_lossy().into_owned())
12430            .collect();
12431        assert!(keys.contains(&"KEPT".to_string()), "got {keys:?}");
12432        assert!(
12433            !keys.contains(&super::CAPTURE_KEEP_ENV.to_string()),
12434            "daemon-private capture key leaked to the child: {keys:?}"
12435        );
12436    }
12437}
12438
12439#[cfg(test)]
12440mod jitter_tests {
12441    use super::jittered_health_delay;
12442    use std::{collections::HashSet, time::Duration};
12443
12444    /// Module ids drawn from a real fleet, so the dispersal claim is about names
12445    /// that actually occur rather than invented ones.
12446    ///
12447    /// This is a SAMPLE, not a registry: the property under test is that distinct
12448    /// ids disperse, which holds for any set of distinct strings. Several entries
12449    /// are already historical (modules get renamed), and that costs nothing here --
12450    /// but it means a reader must not mistake this for the live module set, and a
12451    /// rename sweep will match it without there being anything to change.
12452    const FLEET: [&str; 14] = [
12453        "aft",
12454        "alfonso-core",
12455        "magic-context",
12456        "broca",
12457        "thalamus",
12458        "quota",
12459        "engram",
12460        "plexus",
12461        "cerebellum",
12462        "astrocyte",
12463        "synapse",
12464        "subc-mcp",
12465        "cortexkit-credentials",
12466        "subc-federation",
12467    ];
12468
12469    /// Probes must not converge after a fleet-wide restart.
12470    ///
12471    /// This is the property the jitter exists for: every module reconnects at
12472    /// once, and without dispersal all fourteen would then probe on the same
12473    /// tick forever. Nothing failed visibly when this went untested -- a
12474    /// convergent fleet still probes correctly, just in a burst, so the symptom
12475    /// is a periodic load spike that looks like whatever else is running.
12476    #[test]
12477    fn probe_delays_disperse_across_the_fleet() {
12478        let cadence = Duration::from_secs(30);
12479        let delays: HashSet<Duration> = FLEET
12480            .iter()
12481            .map(|id| jittered_health_delay(id, 0, cadence))
12482            .collect();
12483        assert_eq!(
12484            delays.len(),
12485            FLEET.len(),
12486            "every supervised module must land on its own probe offset"
12487        );
12488    }
12489
12490    /// The offset may only ever DELAY a probe, never bring it forward.
12491    ///
12492    /// A delay below the cadence would probe a module more often than
12493    /// configured, which is the opposite of what an operator asked for and
12494    /// would tighten the failure budget without anyone changing it.
12495    #[test]
12496    fn jitter_only_delays_and_stays_within_one_tenth_of_cadence() {
12497        let cadence = Duration::from_secs(30);
12498        let span = cadence / 10;
12499        for id in FLEET {
12500            for probe_index in 0..8 {
12501                let delay = jittered_health_delay(id, probe_index, cadence);
12502                assert!(
12503                    delay >= cadence,
12504                    "{id}#{probe_index}: jitter must not shorten the cadence"
12505                );
12506                assert!(
12507                    delay < cadence + span,
12508                    "{id}#{probe_index}: jitter must stay inside one tenth of the cadence"
12509                );
12510            }
12511        }
12512    }
12513
12514    /// A module keeps its offset across daemon restarts.
12515    ///
12516    /// The delay is derived rather than randomised precisely so a restart does
12517    /// not re-roll every module into a fresh chance of collision. A random
12518    /// source would satisfy the dispersal test above and quietly lose this.
12519    #[test]
12520    fn a_module_offset_is_stable_across_restarts() {
12521        let cadence = Duration::from_secs(30);
12522        for id in FLEET {
12523            assert_eq!(
12524                jittered_health_delay(id, 0, cadence),
12525                jittered_health_delay(id, 0, cadence),
12526                "{id}: the same module and probe index must produce the same offset"
12527            );
12528        }
12529    }
12530
12531    /// A zero cadence disables probing rather than producing a busy loop.
12532    #[test]
12533    fn zero_cadence_yields_zero_delay() {
12534        assert_eq!(
12535            jittered_health_delay("aft", 0, Duration::ZERO),
12536            Duration::ZERO
12537        );
12538    }
12539}
12540
12541#[cfg(all(test, target_os = "linux"))]
12542mod cgroup_placement_tests {
12543    use super::{
12544        apply_cgroup_placement, remove_module_cgroup, ModuleProtocol, ModuleSpec, SuperviseError,
12545        SupervisedChild,
12546    };
12547    use crate::stderr_tail::{StderrRing, StderrTailConfig};
12548    use cortexkit_test_support::ScratchDir;
12549    use std::{
12550        fs, io,
12551        path::{Path, PathBuf},
12552        sync::{Arc, Mutex},
12553    };
12554    use tokio::process::Command;
12555
12556    #[tokio::test]
12557    async fn unique_spawn_cgroups_clean_up_across_restarts_and_shutdown() {
12558        use super::*;
12559        let dir = ScratchDir::new("unique-spawn-cgroups");
12560        let root = PathBuf::from(format!(
12561            "/sys/fs/cgroup/subc-unique-test-{}-{}",
12562            std::process::id(),
12563            unix_ms_now()
12564        ));
12565        if let Err(error) = fs::create_dir(&root) {
12566            assert!(
12567                std::env::var_os("SUBC_REQUIRE_CGROUP_TEST").is_none(),
12568                "required cgroup test cannot execute: {error}"
12569            );
12570            eprintln!("SKIP unique_spawn_cgroups_clean_up_across_restarts_and_shutdown: {error}");
12571            return;
12572        }
12573        let placement = subc_cgroup::prepare_at(&root).unwrap().unwrap();
12574        let group_count = || {
12575            fs::read_dir(root.join("subc-modules"))
12576                .unwrap()
12577                .map(|entry| entry.unwrap().file_type().unwrap())
12578                .filter(|kind| kind.is_dir())
12579                .count()
12580        };
12581        let supervisor =
12582            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
12583                .with_cgroup_placement(Some(placement.clone()));
12584        let runtime = supervisor.runtime_config();
12585        let mut spec = ModuleSpec {
12586            module_id: "unique-spawn".into(),
12587            program: PathBuf::from("/bin/sleep"),
12588            args: vec!["60".into()],
12589            env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
12590                .into_iter()
12591                .map(|key| (key.into(), dir.to_string_lossy().into_owned()))
12592                .collect(),
12593            reserved: false,
12594            reserved_prefixes: vec![],
12595            protocol: ModuleProtocol::None,
12596            overlap: Default::default(),
12597        };
12598        let spawn = |spec: &ModuleSpec| {
12599            spawn_child(
12600                spec,
12601                None,
12602                None,
12603                &runtime.stderr_ring,
12604                None,
12605                &runtime.child_roster,
12606                Some(&placement),
12607            )
12608            .unwrap()
12609        };
12610        let mut live = spawn(&spec);
12611        for _ in 0..3 {
12612            // A new process can enter the old slot while retirement is pending.
12613            let next = spawn(&spec);
12614            assert_ne!(live.module_id, next.module_id);
12615            live.start_kill().unwrap();
12616            live.wait().await.unwrap();
12617            live = next;
12618            assert!(
12619                live.child.try_wait().unwrap().is_none(),
12620                "retiring the old slot must not kill the replacement"
12621            );
12622            assert_eq!(
12623                group_count(),
12624                1,
12625                "only the live spawn's cgroup should remain"
12626            );
12627        }
12628        supervisor.begin_daemon_shutdown();
12629        let reap = tokio::spawn(async move {
12630            live.wait().await.unwrap();
12631        });
12632        supervisor
12633            .end_children_for_daemon_shutdown(false, std::future::pending())
12634            .await;
12635        reap.await.unwrap();
12636        assert_eq!(group_count(), 0);
12637        // A normal exit uses the same tree-cleanup path as a killed spawn.
12638        spec.program = PathBuf::from("/bin/true");
12639        spec.args.clear();
12640        let fresh_roster = ChildRoster::default();
12641        let mut short = spawn_child(
12642            &spec,
12643            None,
12644            None,
12645            &runtime.stderr_ring,
12646            None,
12647            &fresh_roster,
12648            Some(&placement),
12649        )
12650        .unwrap();
12651        short.wait().await.unwrap();
12652        assert_eq!(group_count(), 0);
12653        spec.module_id = "_".repeat(255);
12654        let mut long_id = spawn_child(
12655            &spec,
12656            None,
12657            None,
12658            &runtime.stderr_ring,
12659            None,
12660            &fresh_roster,
12661            Some(&placement),
12662        )
12663        .unwrap();
12664        long_id.wait().await.unwrap();
12665        assert_eq!(
12666            group_count(),
12667            0,
12668            "valid long module IDs must not exceed cgroup NAME_MAX"
12669        );
12670        fs::remove_dir(root.join("subc-modules")).unwrap();
12671        fs::remove_dir(root).unwrap();
12672        eprintln!("EXECUTED unique_spawn_cgroups_clean_up_across_restarts_and_shutdown");
12673    }
12674
12675    #[test]
12676    fn failed_parent_cgroup_open_is_a_cgroup_supervision_error() {
12677        let path = Path::new("/definitely-missing-subc-cgroup");
12678        let mut command = Command::new("true");
12679        let error = apply_cgroup_placement(
12680            &mut command,
12681            &ModuleSpec {
12682                module_id: "broken-cgroup".to_string(),
12683                program: PathBuf::from("true"),
12684                args: Vec::new(),
12685                env: Vec::new(),
12686                reserved: false,
12687                reserved_prefixes: Vec::new(),
12688                protocol: ModuleProtocol::Subc,
12689                overlap: Default::default(),
12690            },
12691            path,
12692        )
12693        .expect_err("a parent cgroup open failure must reject the supervised spawn");
12694        let reason = error.to_string();
12695
12696        assert!(
12697            matches!(error, SuperviseError::Cgroup { .. }),
12698            "parent cgroup open must be reported as a cgroup supervision error: {reason}"
12699        );
12700        assert!(
12701            reason.contains("/definitely-missing-subc-cgroup/cgroup.procs"),
12702            "parent cgroup open failure must name cgroup.procs: {reason}"
12703        );
12704    }
12705
12706    #[tokio::test]
12707    async fn reaping_a_child_removes_its_empty_module_cgroup() {
12708        let root = ScratchDir::new("supervisor-reap-cgroup");
12709        fs::write(root.join("cgroup.procs"), b"").expect("write scratch cgroup marker");
12710        let placement = subc_cgroup::prepare_at(&root)
12711            .expect("prepare scratch cgroup root")
12712            .expect("scratch root has a cgroup.procs marker");
12713        let module_id = "reaped-module";
12714        let module = placement
12715            .module_path(module_id)
12716            .expect("create scratch module cgroup");
12717        let child = Command::new("true")
12718            .env("XDG_DATA_HOME", root.path())
12719            .env("XDG_RUNTIME_DIR", root.path())
12720            .env("XDG_CONFIG_HOME", root.path())
12721            .spawn()
12722            .expect("spawn short-lived child");
12723        let pid = child.id().expect("spawned child has pid");
12724        let mut child = SupervisedChild {
12725            child,
12726            protocol: ModuleProtocol::Subc,
12727            module_id: module_id.to_string(),
12728            cgroup_placement: Some(placement),
12729            stdout_pump: None,
12730            stderr_pump: None,
12731            stderr_ring: Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default()))),
12732            spawned_at_ms: 0,
12733            spawned_from: PathBuf::from("true"),
12734            spawned_file_identity: None,
12735            process_start_time: None,
12736            process_identity: None,
12737            pid,
12738            roster_guard: None,
12739            #[cfg(target_os = "macos")]
12740            privacy_exec: None,
12741            spawn_failure: None,
12742        };
12743
12744        child.wait().await.expect("reap short-lived child");
12745
12746        assert!(
12747            !module.exists(),
12748            "reaping the supervised child must remove its empty cgroup"
12749        );
12750    }
12751
12752    #[test]
12753    fn non_empty_cgroup_removal_is_reported_without_blocking_teardown() {
12754        let root = ScratchDir::new("supervisor-non-empty-cgroup");
12755        fs::write(root.join("cgroup.procs"), b"").expect("write scratch cgroup marker");
12756        let placement = subc_cgroup::prepare_at(&root)
12757            .expect("prepare scratch cgroup root")
12758            .expect("scratch root has a cgroup.procs marker");
12759        let module = placement
12760            .module_path("surviving-module")
12761            .expect("create scratch module cgroup");
12762        fs::write(module.join("surviving-process"), b"still present")
12763            .expect("make scratch cgroup non-empty");
12764        let (logs, _guard) = crate::router::test_log::log_capture(tracing::Level::WARN);
12765
12766        remove_module_cgroup(&placement, "surviving-module");
12767
12768        let logs = crate::router::test_log::captured_logs(&logs);
12769        assert!(
12770            module.exists(),
12771            "failed removal must leave the cgroup intact"
12772        );
12773        assert!(
12774            logs.contains("could not remove module cgroup after process exit; continuing teardown")
12775                && logs.contains("surviving-module"),
12776            "best-effort removal must report the failure without returning it: {logs}"
12777        );
12778    }
12779
12780    #[test]
12781    fn cgroup_pre_exec_spawn_failure_names_the_cgroup_path() {
12782        let cgroup_path = PathBuf::from("/sys/fs/cgroup/subc-modules/broken-module");
12783        let reason = SuperviseError::Spawn {
12784            program: PathBuf::from("/bin/true"),
12785            source: io::Error::from_raw_os_error(13),
12786            cgroup_path: Some(cgroup_path.clone()),
12787        }
12788        .to_string();
12789
12790        assert!(
12791            reason.contains(&cgroup_path.display().to_string()),
12792            "a pre_exec spawn failure must name the cgroup path: {reason}"
12793        );
12794    }
12795}
12796
12797#[cfg(test)]
12798mod spawn_subscriber_lag_tests {
12799    use super::*;
12800
12801    /// A subscriber whose connection stops draining is dropped once its frame
12802    /// channel fills. The client must learn that from a terminal Error frame
12803    /// after the frames already queued for it, not from a stream that simply
12804    /// goes quiet.
12805    #[tokio::test]
12806    async fn lagged_spawn_subscriber_receives_a_terminal_lagged_error_after_its_queued_frames() {
12807        let feed = SpawnEventFeed::default();
12808        feed.configure_incarnation("lag-incarnation".to_string());
12809        // A one-slot connection queue that nobody reads until the emits are
12810        // done: the forwarder parks on it and the subscriber channel fills.
12811        let (tx, mut rx) = mpsc::channel(1);
12812        feed.subscribe(ConnectionId::new(1), 7, 1, None, FrameSink::new(tx))
12813            .expect("subscribe");
12814        let emitted = SPAWN_SUBSCRIBER_BUFFER + 16;
12815        for index in 0..emitted {
12816            feed.emit_spawned(&format!("lag-module-{index}"), 1000, 0);
12817            // Let the forwarder take what it can so the fill point is the
12818            // subscriber channel, not a scheduling accident.
12819            tokio::task::yield_now().await;
12820        }
12821        assert_eq!(
12822            feed.subscriber_count(),
12823            0,
12824            "the lagged subscriber must be removed"
12825        );
12826
12827        let mut data = Vec::new();
12828        let mut last = None;
12829        loop {
12830            let next = tokio::time::timeout(Duration::from_secs(5), rx.recv())
12831                .await
12832                .expect("the forwarder must finish once the subscriber is dropped");
12833            let Some(outbound) = next else { break };
12834            let frame = outbound.frame;
12835            if frame.header.ty == FrameType::StreamData {
12836                assert!(last.is_none(), "no data may follow the terminal frame");
12837                let event: SpawnEvent = serde_json::from_slice(&frame.body).unwrap();
12838                data.push(event.cursor.seq);
12839            } else {
12840                assert!(last.is_none(), "exactly one terminal frame");
12841                last = Some(frame);
12842            }
12843        }
12844        assert!(!data.is_empty(), "queued frames drain before the terminal");
12845        for pair in data.windows(2) {
12846            assert_eq!(
12847                pair[1],
12848                pair[0] + 1,
12849                "queued frames arrive dense and in order"
12850            );
12851        }
12852        let terminal = last.expect("a lagged subscriber must receive a terminal frame");
12853        assert_eq!(terminal.header.ty, FrameType::Error);
12854        assert_eq!(terminal.header.corr, 7);
12855        let body: subc_protocol::ErrorBody = serde_json::from_slice(&terminal.body).unwrap();
12856        assert_eq!(body.code, SPAWN_SUBSCRIBER_LAGGED_CODE);
12857        let detail = body.detail.expect("lagged error carries detail");
12858        assert_eq!(
12859            detail["first_undelivered_cursor"]["seq"],
12860            data.last().unwrap() + 1,
12861            "the named cursor is the first event the subscriber did not receive"
12862        );
12863        assert_eq!(
12864            detail["first_undelivered_cursor"]["daemon_incarnation"],
12865            "lag-incarnation"
12866        );
12867    }
12868}
12869
12870#[cfg(test)]
12871mod terminal_history_read_concurrency_tests {
12872    use super::*;
12873    use crate::terminal_journal::read_pause;
12874    use cortexkit_test_support::ScratchDir;
12875    use std::sync::mpsc as std_mpsc;
12876
12877    fn journaled_ring(
12878        journal: &Arc<crate::terminal_journal::TerminalJournal>,
12879    ) -> Arc<Mutex<TerminalRing>> {
12880        Arc::new(Mutex::new(
12881            TerminalRing::new(TerminalRingConfig::default(), 1)
12882                .with_journal(Some(Arc::clone(journal))),
12883        ))
12884    }
12885
12886    fn crash(at_ms: u64) -> ExitReport {
12887        ExitReport {
12888            kind: ExitKind::Crash,
12889            code: Some(1),
12890            signal: None,
12891            at_ms,
12892        }
12893    }
12894
12895    /// Record an exit on another thread and report whether it finished within
12896    /// `bound`. The recorder thread is left running if it did not.
12897    fn record_within(
12898        module_id: &'static str,
12899        ring: &Arc<Mutex<TerminalRing>>,
12900        at_ms: u64,
12901        bound: Duration,
12902    ) -> bool {
12903        let ring = Arc::clone(ring);
12904        let (done, done_rx) = std_mpsc::channel();
12905        std::thread::spawn(move || {
12906            record_terminal(
12907                module_id,
12908                &ring,
12909                &SpawnEventFeed::default(),
12910                &crash(at_ms),
12911                TerminalDisposition::Restarting,
12912            );
12913            let _ = done.send(());
12914        });
12915        done_rx.recv_timeout(bound).is_ok()
12916    }
12917
12918    /// A history read in progress must not hold the journal writer (which every
12919    /// module's exit recording needs) or the module's own ring. Exits recorded
12920    /// while the read is paused complete promptly; the paused read answers as of
12921    /// the moment it started, and the next read has each exit exactly once.
12922    #[test]
12923    fn exits_recorded_during_a_paused_history_read_are_not_blocked_or_half_merged() {
12924        let dir = ScratchDir::new("terminal-history-concurrent-read");
12925        let path = dir.join("terminals.jsonl");
12926        let journal = Arc::new(crate::terminal_journal::TerminalJournal::open(
12927            path.clone(),
12928            "daemon".into(),
12929        ));
12930        let reader_ring = journaled_ring(&journal);
12931        let other_ring = journaled_ring(&journal);
12932        assert!(record_within(
12933            "reader-module",
12934            &reader_ring,
12935            10,
12936            Duration::from_secs(5)
12937        ));
12938
12939        let (started, release) = read_pause::install(&path);
12940        let reading = {
12941            let ring = Arc::clone(&reader_ring);
12942            std::thread::spawn(move || durable_terminal_history_of(&ring, "reader-module"))
12943        };
12944        started
12945            .recv_timeout(Duration::from_secs(5))
12946            .expect("the history read reached its pause");
12947
12948        let bound = Duration::from_secs(1);
12949        assert!(
12950            record_within("other-module", &other_ring, 20, bound),
12951            "another module's exit waited on a history read (journal writer held)"
12952        );
12953        assert!(
12954            record_within("reader-module", &reader_ring, 30, bound),
12955            "the read module's own exit waited on its history read (ring held)"
12956        );
12957
12958        drop(release);
12959        let paused = reading.join().unwrap();
12960        assert_eq!(
12961            paused.entries.iter().map(|e| e.at_ms).collect::<Vec<_>>(),
12962            vec![10],
12963            "an exit recorded after the read began lands in neither half of it"
12964        );
12965        assert_eq!(paused.journal_skipped_lines, 0);
12966        assert_eq!(paused.journal_read_errors, 0);
12967
12968        let after = durable_terminal_history_of(&reader_ring, "reader-module");
12969        assert_eq!(
12970            after.entries.iter().map(|e| e.at_ms).collect::<Vec<_>>(),
12971            vec![10, 30],
12972            "the next read merges ring and journal with no duplicate"
12973        );
12974        assert_eq!(after.journal_skipped_lines, 0);
12975    }
12976}
12977
12978/// What a restart does with the exited process's output readers. These drive
12979/// the same settle functions the supervisor calls, with a reader the test
12980/// holds, so a reader that has not been scheduled by the bound is a controlled
12981/// input rather than something only a loaded machine produces.
12982#[cfg(test)]
12983mod stderr_settle_tests {
12984    use std::{
12985        future::Future,
12986        io,
12987        pin::Pin,
12988        sync::{Arc, Mutex},
12989        task::{Context, Poll},
12990        time::Duration,
12991    };
12992
12993    use tokio::{
12994        io::{AsyncRead, ReadBuf},
12995        sync::oneshot,
12996        time::Instant,
12997    };
12998
12999    use super::{settle_stderr_pump, settle_stdout_pump, StderrPump};
13000    use crate::stderr_tail::{
13001        pump_stderr_to, pump_stdout_to, split_capture_stamp, untimed, CaptureState,
13002        ChildOutputSink, OutputSink, StderrRing, StderrTailConfig, TailEntry,
13003    };
13004
13005    const BOUND: Duration = Duration::from_millis(250);
13006
13007    /// Yields `before`, then stays pending until the gate is released, then
13008    /// yields `after` and reaches EOF. The bytes after the gate were written
13009    /// by a process that has already exited; only the reader is behind.
13010    struct HeldReader {
13011        before: Option<Vec<u8>>,
13012        gate: Option<oneshot::Receiver<()>>,
13013        after: io::Cursor<Vec<u8>>,
13014    }
13015
13016    impl AsyncRead for HeldReader {
13017        fn poll_read(
13018            mut self: Pin<&mut Self>,
13019            cx: &mut Context<'_>,
13020            buf: &mut ReadBuf<'_>,
13021        ) -> Poll<io::Result<()>> {
13022            if let Some(bytes) = self.before.take() {
13023                buf.put_slice(&bytes);
13024                return Poll::Ready(Ok(()));
13025            }
13026            if let Some(gate) = self.gate.as_mut() {
13027                match Pin::new(gate).poll(cx) {
13028                    Poll::Pending => return Poll::Pending,
13029                    Poll::Ready(_) => self.gate = None,
13030                }
13031            }
13032            Pin::new(&mut self.after).poll_read(cx, buf)
13033        }
13034    }
13035
13036    struct DiscardSink;
13037
13038    impl OutputSink for DiscardSink {
13039        fn write_line(&mut self, _line: &[u8]) {}
13040    }
13041
13042    fn line(text: &str) -> TailEntry {
13043        TailEntry::Line {
13044            text: text.to_string(),
13045            truncated: false,
13046            at_ms: None,
13047        }
13048    }
13049
13050    fn lock(ring: &Arc<Mutex<StderrRing>>) -> std::sync::MutexGuard<'_, StderrRing> {
13051        ring.lock().unwrap()
13052    }
13053
13054    /// Start a reader for a new process generation that delivers `before`
13055    /// immediately and `after` only once the returned sender fires (or is
13056    /// dropped).
13057    fn held_pump(
13058        ring: &Arc<Mutex<StderrRing>>,
13059        before: &str,
13060        after: &str,
13061    ) -> (StderrPump, oneshot::Sender<()>) {
13062        let generation = lock(ring).begin_process();
13063        let (release, gate) = oneshot::channel();
13064        let reader = HeldReader {
13065            before: Some(before.as_bytes().to_vec()),
13066            gate: Some(gate),
13067            after: io::Cursor::new(after.as_bytes().to_vec()),
13068        };
13069        let task = tokio::spawn(pump_stderr_to(
13070            reader,
13071            Arc::clone(ring),
13072            generation,
13073            DiscardSink,
13074        ));
13075        (StderrPump { task, generation }, release)
13076    }
13077
13078    async fn wait_until(ring: &Arc<Mutex<StderrRing>>, done: impl Fn(&StderrRing) -> bool) {
13079        for _ in 0..1000 {
13080            if done(&lock(ring)) {
13081                return;
13082            }
13083            tokio::time::sleep(Duration::from_millis(1)).await;
13084        }
13085        panic!(
13086            "ring never reached the expected state: {:?}",
13087            lock(ring).snapshot(None, None)
13088        );
13089    }
13090
13091    #[tokio::test(start_paused = true)]
13092    async fn a_crash_line_the_reader_had_not_reached_by_the_bound_is_kept_before_the_restart() {
13093        let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
13094        let (pump, release) = held_pump(&ring, "booting\n", "config error: missing storage\n");
13095
13096        settle_stderr_pump("crasher", &ring, pump, BOUND).await;
13097        let before_release = lock(&ring).snapshot(None, None);
13098        assert!(
13099            matches!(before_release.capture, CaptureState::Incomplete { .. }),
13100            "a reader that has not reached EOF cannot claim a whole tail: {before_release:?}"
13101        );
13102
13103        // The restart: the next process starts and writes before the old
13104        // reader catches up.
13105        let next = lock(&ring).begin_process();
13106        lock(&ring).push_line_from(next, "next process booting");
13107        release.send(()).unwrap();
13108        wait_until(&ring, |ring| {
13109            ring.snapshot(None, None).capture == CaptureState::Captured
13110        })
13111        .await;
13112
13113        assert_eq!(
13114            untimed(lock(&ring).snapshot(None, None).entries),
13115            vec![
13116                line("booting"),
13117                line("config error: missing storage"),
13118                TailEntry::ProcessStart,
13119                line("next process booting"),
13120            ],
13121            "the crash's last line must survive a slow reader and stay in the crashed process's section"
13122        );
13123    }
13124
13125    #[tokio::test(start_paused = true)]
13126    async fn a_pipe_held_open_by_a_descendant_reads_incomplete_without_delaying_the_restart_past_the_bound(
13127    ) {
13128        let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
13129        // `_held` is never fired: a descendant keeps the pipe open for the
13130        // whole test.
13131        let (pump, _held) = held_pump(&ring, "parent exiting\n", "");
13132
13133        let started = Instant::now();
13134        settle_stderr_pump("orphaning", &ring, pump, BOUND).await;
13135        assert_eq!(
13136            started.elapsed(),
13137            BOUND,
13138            "the restart must wait exactly the bound for a pipe that stays open, no longer"
13139        );
13140
13141        let next = lock(&ring).begin_process();
13142        lock(&ring).push_line_from(next, "next process booting");
13143        tokio::time::sleep(Duration::from_secs(60)).await;
13144
13145        let snapshot = lock(&ring).snapshot(None, None);
13146        match &snapshot.capture {
13147            CaptureState::Incomplete { reason } => assert!(
13148                reason.contains("had not reached EOF") && reason.contains("250ms"),
13149                "the reason must say what is missing and after how long: {reason}"
13150            ),
13151            other => panic!("expected Incomplete while the pipe is held open, got {other:?}"),
13152        }
13153        assert_eq!(
13154            untimed(snapshot.entries),
13155            vec![
13156                line("parent exiting"),
13157                TailEntry::ProcessStart,
13158                line("next process booting"),
13159            ]
13160        );
13161    }
13162
13163    #[tokio::test(start_paused = true)]
13164    async fn a_reader_that_reaches_eof_within_the_bound_leaves_the_tail_captured() {
13165        let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
13166        let (pump, release) = held_pump(&ring, "one\n", "two\n");
13167        release.send(()).unwrap();
13168
13169        settle_stderr_pump("clean", &ring, pump, BOUND).await;
13170
13171        let snapshot = lock(&ring).snapshot(None, None);
13172        assert_eq!(snapshot.capture, CaptureState::Captured);
13173        assert_eq!(untimed(snapshot.entries), vec![line("one"), line("two")]);
13174    }
13175
13176    #[tokio::test(start_paused = true)]
13177    async fn stdout_buffered_past_the_exit_drain_bound_reaches_the_capture_file() {
13178        const BURST: usize = 150;
13179        let capture = cortexkit_test_support::ScratchDir::new("stdout-exit-drain");
13180        let path = capture.join("two-pipe.stderr.log");
13181        let sink = ChildOutputSink::open(&path, cortexkit_log::Retention::default()).unwrap();
13182        let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
13183        let generation = lock(&ring).begin_process();
13184        let stdout = (0..BURST)
13185            .map(|i| format!("out-{i:04}-{}\n", "o".repeat(64)))
13186            .collect::<String>();
13187        let stderr = (0..BURST)
13188            .map(|i| format!("err-{i:04}-{}\n", "e".repeat(64)))
13189            .collect::<String>();
13190        // Model a child that has written both bursts and exited. Hold the stdout
13191        // reader after one line and a partial prefix, as if pipe readiness or
13192        // capture-file writes had delayed it. The final line also needs EOF to
13193        // flush: neither complete nor partial buffered output may be discarded.
13194        let first_chunk = stdout.find('\n').unwrap() + 1 + "out-0001-".len();
13195        let (release, gate) = oneshot::channel();
13196        let reader = HeldReader {
13197            before: Some(stdout.as_bytes()[..first_chunk].to_vec()),
13198            gate: Some(gate),
13199            after: io::Cursor::new(stdout.as_bytes()[first_chunk..stdout.len() - 1].to_vec()),
13200        };
13201        let (finished, completion) = oneshot::channel();
13202        let stdout_sink = sink.clone();
13203        let pump = tokio::spawn(async move {
13204            pump_stdout_to(reader, stdout_sink).await;
13205            finished.send(()).unwrap();
13206        });
13207        pump_stderr_to(io::Cursor::new(stderr.into_bytes()), ring, generation, sink).await;
13208
13209        let started = Instant::now();
13210        settle_stdout_pump("two-pipe", pump, BOUND).await;
13211        assert_eq!(
13212            started.elapsed(),
13213            BOUND,
13214            "a late reader must not hold up restart"
13215        );
13216        assert_eq!(
13217            std::fs::read_to_string(&path).unwrap().lines().count(),
13218            BURST + 1
13219        );
13220
13221        release
13222            .send(())
13223            .expect("stdout reader must survive the exit drain bound");
13224        tokio::time::timeout(Duration::from_secs(1), completion)
13225            .await
13226            .expect("released stdout reader must reach EOF")
13227            .expect("stdout capture must finish at EOF, not be cancelled");
13228        let contents = std::fs::read_to_string(&path).unwrap();
13229        let mut lines = contents
13230            .lines()
13231            .map(|line| split_capture_stamp(line).expect("capture stamp").1)
13232            .collect::<Vec<_>>();
13233        let mut expected = (0..BURST)
13234            .flat_map(|i| {
13235                [
13236                    format!("out-{i:04}-{}", "o".repeat(64)),
13237                    format!("err-{i:04}-{}", "e".repeat(64)),
13238                ]
13239            })
13240            .collect::<Vec<_>>();
13241        lines.sort_unstable();
13242        expected.sort_unstable();
13243        assert_eq!(
13244            lines, expected,
13245            "both pipes must retain every complete line exactly once"
13246        );
13247    }
13248}
13249
13250/// Containment of a module's process tree (issue #109).
13251///
13252/// The behaviour these defend against is a module helper surviving its module:
13253/// on a real machine the Synapse embedding module's CUDA worker holds ~2.2 GB of
13254/// VRAM, so a leaked grandchild is a leaked GPU allocation, and a day of restarts
13255/// compounds it.
13256///
13257/// They run against the SUPERVISOR rather than the job-object crate because the
13258/// claim is about teardown: a crate-level test proves a job can reap a tree, not
13259/// that the daemon's drain path reaches it.
13260///
13261/// Windows-only, like the mechanism. On Unix this arm compiles out; the cgroup
13262/// lane there is a separate containment path with its own tests.
13263#[cfg(all(test, windows))]
13264mod job_containment_tests {
13265    use super::*;
13266    use cortexkit_test_support::ScratchDir;
13267    use std::{
13268        path::{Path, PathBuf},
13269        sync::{Arc, Mutex},
13270        time::{Duration, Instant},
13271    };
13272
13273    /// The stub, expected beside this test executable.
13274    ///
13275    /// The existence check is here for the reason its twin at `fake_aft_stub_path`
13276    /// documents: `--lib` does not build `[[bin]]` targets, and a bare spawn
13277    /// failure then reads as a broken test rather than an unbuilt dependency.
13278    fn stub_path() -> PathBuf {
13279        let mut path = std::env::current_exe().expect("current_exe available in tests");
13280        path.pop();
13281        path.pop();
13282        path.push("fake-aft-stub.exe");
13283        assert!(
13284            path.exists(),
13285            "fake-aft-stub not built at {}: run `cargo test -p subc-core` (which builds \
13286             [[bin]] targets) rather than `cargo test -p subc-core --lib` (which does not)",
13287            path.display()
13288        );
13289        path
13290    }
13291
13292    /// Poll for the grandchild pid the stub records, and parse it.
13293    fn read_grandchild_pid(path: &Path) -> u32 {
13294        let deadline = Instant::now() + Duration::from_secs(10);
13295        loop {
13296            if let Ok(contents) = std::fs::read_to_string(path) {
13297                if let Ok(pid) = contents.trim().parse() {
13298                    return pid;
13299                }
13300            }
13301            assert!(
13302                Instant::now() < deadline,
13303                "the stub never recorded a grandchild pid at {}",
13304                path.display()
13305            );
13306            std::thread::sleep(Duration::from_millis(10));
13307        }
13308    }
13309
13310    /// Everything one fixture run needs, so the two tests below differ in exactly
13311    /// one place: whether the child is contained.
13312    struct Fixture {
13313        _dir: ScratchDir,
13314        module_id: String,
13315        grandchild: u32,
13316        child: Option<SupervisedChild>,
13317        registry: Arc<Registry>,
13318        snapshot: Arc<Mutex<SupervisorSnapshot>>,
13319        terminal_ring: Arc<Mutex<TerminalRing>>,
13320        spawn_events: SpawnEventFeed,
13321    }
13322
13323    fn fixture(label: &str, module_id: &str) -> Fixture {
13324        let dir = ScratchDir::new(label);
13325        let pid_file = dir.join("grandchild.pid");
13326        let supervisor = Supervisor::new_for_test(
13327            Arc::new(Registry::default()),
13328            RestartPolicy::new(3, Duration::ZERO),
13329        );
13330        let runtime = supervisor.runtime_config();
13331        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
13332        let spec = ModuleSpec {
13333            module_id: module_id.to_string(),
13334            program: stub_path(),
13335            // Zero args deliberately: a `--subc` argument would make the stub dial
13336            // a daemon that is not there, and the failure would land in the same
13337            // stderr ring this fixture exists to keep quiet.
13338            args: Vec::new(),
13339            env: vec![
13340                ("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string()),
13341                (
13342                    "FAKE_AFT_GRANDCHILD_PID_FILE".to_string(),
13343                    pid_file.display().to_string(),
13344                ),
13345            ],
13346            reserved: false,
13347            reserved_prefixes: Vec::new(),
13348            protocol: ModuleProtocol::Subc,
13349            overlap: Default::default(),
13350        };
13351        let child = spawn_and_mark_running(&spec, &runtime, &snapshot)
13352            .expect("spawn the supervised fixture");
13353        let grandchild = read_grandchild_pid(&pid_file);
13354        Fixture {
13355            _dir: dir,
13356            module_id: module_id.to_string(),
13357            grandchild,
13358            child: Some(child),
13359            registry: Arc::new(Registry::default()),
13360            snapshot,
13361            terminal_ring: Arc::clone(&runtime.terminal_ring),
13362            spawn_events: SpawnEventFeed::default(),
13363        }
13364    }
13365
13366    impl Fixture {
13367        /// Drain through the supervisor's own teardown path.
13368        async fn drain(&mut self) {
13369            let child = self
13370                .child
13371                .take()
13372                .expect("the fixture child is still present");
13373            drain_child_to_state(
13374                &self.module_id,
13375                ModuleProtocol::Subc,
13376                // No forwarding table in this fixture, so nothing reaches the
13377                // child over a connection.
13378                StopNotice::NotSent,
13379                &self.registry,
13380                None,
13381                &self.snapshot,
13382                &self.terminal_ring,
13383                &self.spawn_events,
13384                child,
13385                Duration::from_millis(500),
13386                ModuleState::Stopped,
13387                Some(false),
13388            )
13389            .await
13390            .expect("drain the supervised fixture");
13391        }
13392    }
13393
13394    /// Teardown reaps the grandchild, not merely the direct child.
13395    ///
13396    /// This is the assertion the change exists for. Before containment the
13397    /// grandchild survived: it is a separate process, and `start_kill` is
13398    /// `TerminateProcess` scoped to one pid.
13399    #[tokio::test]
13400    async fn teardown_reaps_the_grandchild() {
13401        let mut fixture = fixture("teardown-grandchild", "tree-teardown");
13402        let grandchild = fixture.grandchild;
13403
13404        assert!(
13405            subc_jobobject::process_exists(grandchild),
13406            "grandchild {grandchild} must be alive before teardown, or this proves nothing"
13407        );
13408
13409        fixture.drain().await;
13410
13411        assert!(
13412            subc_jobobject::wait_for_process_exit(grandchild, Duration::from_secs(10)),
13413            "grandchild {grandchild} outlived module teardown: the tree was not contained"
13414        );
13415    }
13416
13417    /// The mutation control: with containment withheld, the grandchild survives
13418    /// the same kill.
13419    ///
13420    /// This is the defect reproduction from #109 — a direct-child kill reaches
13421    /// one pid, and the grandchild is a different process. It spawns OUTSIDE the
13422    /// supervisor because `spawn_and_mark_running` now always contains on
13423    /// Windows, which is the point: there is no longer a path that spawns
13424    /// uncontained, so the control has to construct one.
13425    ///
13426    /// Its job is to keep `teardown_reaps_the_grandchild` honest. If the
13427    /// grandchild ever dies here, that test is passing for a reason unrelated to
13428    /// the job object and the containment claim is unproven.
13429    #[test]
13430    fn an_uncontained_grandchild_survives_a_direct_child_kill() {
13431        let dir = ScratchDir::new("teardown-uncontained");
13432        let pid_file = dir.join("grandchild.pid");
13433        let mut child = std::process::Command::new(stub_path())
13434            .env("FAKE_AFT_NEVER_CONNECT", "1")
13435            .env(
13436                "FAKE_AFT_GRANDCHILD_PID_FILE",
13437                pid_file.display().to_string(),
13438            )
13439            .stdin(std::process::Stdio::null())
13440            .stdout(std::process::Stdio::null())
13441            .stderr(std::process::Stdio::null())
13442            .spawn()
13443            .expect("spawn the uncontained fixture");
13444        let grandchild = read_grandchild_pid(&pid_file);
13445
13446        // Exactly what the pre-fix teardown did: kill the direct child.
13447        child.kill().expect("kill the direct child");
13448        let _ = child.wait();
13449
13450        assert!(
13451            subc_jobobject::process_exists(grandchild),
13452            "grandchild {grandchild} died with the direct child, so this control no longer \
13453             distinguishes contained from uncontained teardown and the regression test is \
13454             passing vacuously"
13455        );
13456
13457        // The orphan this control demonstrates is the leak the fix prevents, so
13458        // the control must not leave one behind.
13459        kill_tree(grandchild);
13460    }
13461
13462    /// Crash durability: closing the containment handle reaps the tree with no
13463    /// teardown code running at all.
13464    ///
13465    /// This is the case `taskkill /T` cannot cover — a daemon that dies cannot
13466    /// call anything — and it is why containment is a kernel property of the
13467    /// handle rather than a step in the drain. Discovered by getting the
13468    /// mutation control wrong: clearing `job` to "disable" containment instead
13469    /// killed the tree, which is the guarantee, not a mistake.
13470    #[tokio::test]
13471    async fn dropping_containment_reaps_the_grandchild() {
13472        let mut fixture = fixture("drop-containment", "tree-drop");
13473        let grandchild = fixture.grandchild;
13474
13475        assert!(subc_jobobject::process_exists(grandchild));
13476
13477        // No `drain` call, no kill: dropping the handle is the entire mechanism.
13478        fixture.child.as_mut().expect("child present").job = None;
13479
13480        assert!(
13481            subc_jobobject::wait_for_process_exit(grandchild, Duration::from_secs(10)),
13482            "grandchild {grandchild} survived the containment handle closing, so a daemon \
13483             crash would leave the tree behind"
13484        );
13485    }
13486
13487    /// Kill a pid and its tree, then confirm it is gone.
13488    fn kill_tree(pid: u32) {
13489        let _ = std::process::Command::new("taskkill.exe")
13490            .args(["/PID", &pid.to_string(), "/T", "/F"])
13491            .stdin(std::process::Stdio::null())
13492            .stdout(std::process::Stdio::null())
13493            .stderr(std::process::Stdio::null())
13494            .status();
13495        assert!(
13496            subc_jobobject::wait_for_process_exit(pid, Duration::from_secs(10)),
13497            "could not clean up grandchild {pid}"
13498        );
13499    }
13500}
13501
13502#[cfg(test)]
13503mod privacy_trampoline_configuration_tests {
13504    #[tokio::test]
13505    #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
13506    async fn macos_spawn_without_a_configured_trampoline_refuses_by_name() {
13507        #[cfg(target_os = "macos")]
13508        {
13509            let supervisor = super::Supervisor::new(
13510                std::sync::Arc::new(crate::Registry::default()),
13511                super::RestartPolicy::default(),
13512            );
13513            let error = supervisor.spawn(spec()).unwrap_err();
13514            assert!(
13515                error
13516                    .to_string()
13517                    .contains("no privacy trampoline configured"),
13518                "{error}"
13519            );
13520        }
13521    }
13522
13523    #[tokio::test]
13524    #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
13525    async fn macos_wrong_trampoline_is_rejected_by_the_startup_probe() {
13526        #[cfg(target_os = "macos")]
13527        {
13528            let supervisor = super::Supervisor::new(
13529                std::sync::Arc::new(crate::Registry::default()),
13530                super::RestartPolicy::default(),
13531            )
13532            .with_privacy_trampoline(std::env::current_exe().unwrap());
13533            let error = supervisor.spawn(spec()).unwrap_err();
13534            assert!(
13535                error
13536                    .to_string()
13537                    .contains("binary does not implement the privacy trampoline protocol"),
13538                "{error}"
13539            );
13540        }
13541    }
13542
13543    #[cfg(target_os = "macos")]
13544    fn spec() -> super::ModuleSpec {
13545        super::ModuleSpec {
13546            module_id: "privacy-configuration".into(),
13547            program: "/bin/sleep".into(),
13548            args: vec!["30".into()],
13549            env: vec![],
13550            reserved: false,
13551            reserved_prefixes: vec![],
13552            protocol: subc_control::ModuleProtocol::None,
13553            overlap: super::ModuleOverlap::Exclusive,
13554        }
13555    }
13556}
13557
13558#[cfg(test)]
13559mod privacy_exec_boundary_tests {
13560    #[cfg(target_os = "macos")]
13561    use super::*;
13562    #[cfg(target_os = "macos")]
13563    use std::{
13564        io::{Read, Write},
13565        net::{TcpListener, TcpStream},
13566    };
13567
13568    /// Unit-test-only pause at the actual early image read, not at a later
13569    /// status read. Production supervisors never inspect this environment key.
13570    #[cfg(target_os = "macos")]
13571    pub(super) fn before_image_sample(spec: &ModuleSpec, pid: u32) {
13572        if let Some((_, path)) = spec
13573            .env
13574            .iter()
13575            .find(|(key, _)| key == "SUBC_TEST_PRIVACY_SAMPLE_BARRIER")
13576        {
13577            let mut barrier = TcpStream::connect(path).unwrap();
13578            barrier
13579                .set_read_timeout(Some(Duration::from_secs(30)))
13580                .unwrap();
13581            barrier.write_all(&pid.to_ne_bytes()).unwrap();
13582            let mut release = [0];
13583            barrier.read_exact(&mut release).unwrap();
13584            assert_eq!(&release, b"X");
13585        }
13586    }
13587
13588    #[cfg(target_os = "macos")]
13589    fn spec(program: &str, args: &[&str]) -> ModuleSpec {
13590        ModuleSpec {
13591            module_id: "privacy-boundary".into(),
13592            program: program.into(),
13593            args: args.iter().map(|arg| (*arg).into()).collect(),
13594            env: vec![],
13595            reserved: false,
13596            reserved_prefixes: vec![],
13597            protocol: ModuleProtocol::None,
13598            overlap: ModuleOverlap::Exclusive,
13599        }
13600    }
13601
13602    #[cfg(target_os = "macos")]
13603    async fn accept(listener: TcpListener) -> TcpStream {
13604        // Socket readiness, not elapsed time, establishes both pause points.
13605        let listener = tokio::net::TcpListener::from_std({
13606            listener.set_nonblocking(true).unwrap();
13607            listener
13608        })
13609        .unwrap();
13610        let (stream, _) = tokio::time::timeout(Duration::from_secs(30), listener.accept())
13611            .await
13612            .unwrap()
13613            .unwrap();
13614        let stream = stream.into_std().unwrap();
13615        stream.set_nonblocking(false).unwrap();
13616        stream
13617            .set_read_timeout(Some(Duration::from_secs(30)))
13618            .unwrap();
13619        stream
13620    }
13621
13622    #[tokio::test]
13623    #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
13624    async fn macos_roster_withholds_a_nonnull_trampoline_image_until_exec_confirmation() {
13625        #[cfg(target_os = "macos")]
13626        {
13627            let root = cortexkit_test_support::ScratchDir::new("privacy-roster-barrier");
13628            // Loopback sockets also work when the replay adapter's TMPDIR is
13629            // longer than Darwin's Unix-domain socket path limit.
13630            let exec_listener = TcpListener::bind("127.0.0.1:0").unwrap();
13631            let sample_listener = TcpListener::bind("127.0.0.1:0").unwrap();
13632            let record = root.join("live-children.json");
13633            let supervisor =
13634                Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
13635                    .with_live_children_record(&record);
13636            let runtime = supervisor.runtime_config();
13637            let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
13638            let mut spec = spec("/bin/sleep", &["30"]);
13639            spec.env = vec![
13640                (
13641                    "SUBC_TEST_PRIVACY_EXEC_BARRIER".into(),
13642                    exec_listener.local_addr().unwrap().to_string(),
13643                ),
13644                (
13645                    "SUBC_TEST_PRIVACY_SAMPLE_BARRIER".into(),
13646                    sample_listener.local_addr().unwrap().to_string(),
13647                ),
13648            ];
13649            let spawn = tokio::task::spawn_blocking(move || {
13650                spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap()
13651            });
13652            let mut sample = accept(sample_listener).await;
13653            let mut pid = [0; 4];
13654            sample.read_exact(&mut pid).unwrap();
13655            let pid = u32::from_ne_bytes(pid);
13656            let mut exec = accept(exec_listener).await;
13657            let mut ready = [0];
13658            exec.read_exact(&mut ready).unwrap();
13659            assert_eq!(&ready, b"R");
13660            // The early read is guaranteed to see a real, non-null trampoline
13661            // image: the fixture has reached its barrier and cannot exec yet.
13662            let trampoline = subc_os::file_identity(&test_privacy_trampoline()).unwrap();
13663            assert_eq!(
13664                observe_spawned_image(pid).unwrap().executable,
13665                Some(trampoline)
13666            );
13667            sample.write_all(b"X").unwrap();
13668            let mut child = spawn.await.unwrap();
13669            let early = crate::live_children::read_record(&record).unwrap();
13670            assert_eq!(early.len(), 1);
13671            assert_eq!(early[0].pid, pid);
13672            assert_eq!(
13673                early[0].executable, None,
13674                "unconfirmed trampoline image entered the roster"
13675            );
13676            assert!(child.report_ready.get().is_none());
13677            // The barrier's duration is unrelated to the production five-second
13678            // exec budget. Start the test's confirmation budget upon release.
13679            child.privacy_exec.as_mut().unwrap().deadline =
13680                tokio::time::Instant::now() + Duration::from_secs(30);
13681            exec.write_all(b"X").unwrap();
13682            child.confirm_privacy_exec().await;
13683            assert_eq!(child.spawn_failure, None);
13684            assert!(child.report_ready.get().is_some());
13685            let confirmed = crate::live_children::read_record(&record).unwrap();
13686            let module = subc_os::file_identity(std::path::Path::new("/bin/sleep")).unwrap();
13687            assert_ne!(module, trampoline);
13688            assert_eq!(confirmed[0].executable, Some(module.into()));
13689            child.start_kill().unwrap();
13690            child.wait().await.unwrap();
13691            child.release_roster();
13692        }
13693    }
13694
13695    #[tokio::test]
13696    #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
13697    async fn macos_already_exited_121_in_try_wait_is_a_module_exit_not_a_trampoline_refusal() {
13698        #[cfg(target_os = "macos")]
13699        {
13700            let registry = Arc::new(Registry::default());
13701            let policy = RestartPolicy::new(0, Duration::ZERO);
13702            let supervisor = Supervisor::new_for_test(Arc::clone(&registry), policy);
13703            let runtime = supervisor.runtime_config();
13704            let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
13705            let spec = spec("/bin/sh", &["-c", "exit 121"]);
13706            // Drive spawn and confirmation separately instead of starting the
13707            // monitor. WNOWAIT observes a real exit without consuming its status,
13708            // so confirmation's first try_wait must take the already-exited arm.
13709            let mut child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
13710            let pid = child.pid;
13711            tokio::task::spawn_blocking(move || {
13712                subc_os::fork_exec_test::wait_for_child_exit_without_reaping(pid)
13713            })
13714            .await
13715            .unwrap()
13716            .unwrap();
13717            child.privacy_exec.as_mut().unwrap().deadline =
13718                tokio::time::Instant::now() + Duration::from_secs(30);
13719            let status = child.wait().await.unwrap();
13720            assert_eq!(status.code(), Some(121));
13721            assert!(child.privacy_exec.is_none());
13722            assert!(
13723                child.report_ready.get().is_none(),
13724                "an exited module must not publish a live pid"
13725            );
13726            let report = classify_reaped_child_exit(&snapshot, &child, &status);
13727            on_child_exit(
13728                &spec,
13729                policy,
13730                &registry,
13731                &snapshot,
13732                &runtime.terminal_ring,
13733                &runtime.spawn_events,
13734                &runtime.child_roster,
13735                report,
13736            )
13737            .await;
13738            let state = lock_snapshot(&snapshot).unwrap();
13739            assert_eq!(state.state, ModuleState::Failed);
13740            assert_eq!(state.last_exit.as_ref().unwrap().code, Some(121));
13741            assert_eq!(state.reported_pid(), None);
13742            drop(state);
13743            let history = runtime.terminal_ring.lock().unwrap().snapshot();
13744            assert_eq!(history.entries.len(), 1);
13745            let terminal = &history.entries[0];
13746            assert_eq!(terminal.exit_code, Some(121));
13747            assert_eq!(terminal.exit_kind, subc_control::TerminalExitKind::Crash);
13748            assert_eq!(terminal.disposition, TerminalDisposition::Failed);
13749            assert_eq!(
13750                terminal.disposition_detail.as_deref(),
13751                Some(policy.budget_exhausted_detail().as_str()),
13752                "module exit 121 was classified as a trampoline refusal: {terminal:?}"
13753            );
13754            assert_eq!(child.spawn_failure, None);
13755            child.release_roster();
13756        }
13757    }
13758}
13759
13760/// The daemon's real spawn path hands a subc-wire child its launch nonce on
13761/// descriptor 3, without an environment copy. The shell records the nonce
13762/// and its environment after exec so these tests observe the real handover.
13763#[cfg(all(test, unix))]
13764mod launch_nonce_descriptor_tests {
13765    use super::{ChildRoster, ModuleProtocol, ModuleSpec, SupervisorHandle};
13766    use crate::stderr_tail::{StderrRing, StderrTailConfig};
13767    use cortexkit_test_support::ScratchDir;
13768    use std::{
13769        path::PathBuf,
13770        sync::{Arc, Mutex},
13771        time::{Duration, Instant},
13772    };
13773
13774    async fn probe(role: super::SpawnRole) {
13775        let scratch = ScratchDir::new("launch-nonce-descriptor");
13776        let fd_copy = scratch.join("from-descriptor");
13777        let env_copy = scratch.join("environment");
13778        let script = format!(
13779            "env > '{env}.tmp' && mv '{env}.tmp' '{env}'; cat <&3 > '{fd}.tmp' && mv '{fd}.tmp' '{fd}'; sleep 30",
13780            fd = fd_copy.display(), env = env_copy.display(),
13781        );
13782        let xdg = |name: &str| (name.to_string(), scratch.join(name).display().to_string());
13783        let spec = ModuleSpec {
13784            module_id: "nonce-descriptor-probe".to_string(),
13785            program: PathBuf::from("/bin/sh"),
13786            args: vec!["-c".to_string(), script],
13787            env: vec![
13788                xdg("XDG_DATA_HOME"),
13789                xdg("XDG_RUNTIME_DIR"),
13790                xdg("XDG_CONFIG_HOME"),
13791                ("SUBC_LAUNCH_NONCE".to_string(), "must-not-leak".to_string()),
13792            ],
13793            reserved: true,
13794            reserved_prefixes: Vec::new(),
13795            protocol: ModuleProtocol::Subc,
13796            overlap: Default::default(),
13797        };
13798        let handle = SupervisorHandle::new();
13799        let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
13800        let roster = ChildRoster::default();
13801        #[cfg(target_os = "macos")]
13802        {
13803            let path = super::test_privacy_trampoline();
13804            roster.set_privacy_trampoline(super::probe_privacy_trampoline(&path).map(|()| path));
13805        }
13806        let child = super::spawn_child_in_slot(
13807            &spec,
13808            None,
13809            Some(&handle),
13810            &ring,
13811            None,
13812            &roster,
13813            #[cfg(target_os = "linux")]
13814            None,
13815            role,
13816            matches!(role, super::SpawnRole::SwapCandidate),
13817        )
13818        .expect("spawn probe");
13819        let deadline = Instant::now() + Duration::from_secs(10);
13820        while !(fd_copy.exists() && env_copy.exists()) {
13821            assert!(Instant::now() < deadline, "probe never wrote its copies");
13822            tokio::time::sleep(Duration::from_millis(20)).await;
13823        }
13824        let nonce = std::fs::read_to_string(fd_copy).unwrap();
13825        assert!(!nonce.is_empty());
13826        let environment = std::fs::read_to_string(env_copy).unwrap();
13827        assert!(environment
13828            .lines()
13829            .any(|line| line.starts_with("SUBC_LAUNCH_NONCE_FD=3:")));
13830        let copy = environment
13831            .lines()
13832            .find_map(|line| line.strip_prefix("SUBC_LAUNCH_NONCE="));
13833        assert_eq!(
13834            copy, None,
13835            "Unix children must never receive the environment nonce"
13836        );
13837        if matches!(role, super::SpawnRole::Plain) {
13838            assert_eq!(
13839                handle.spawn_nonce(&spec.module_id).as_deref(),
13840                Some(nonce.as_str())
13841            );
13842        }
13843        drop(child);
13844    }
13845
13846    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
13847    async fn a_spawned_module_receives_its_nonce_only_on_descriptor_3() {
13848        probe(super::SpawnRole::Plain).await;
13849    }
13850
13851    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
13852    async fn swap_candidate_receives_its_nonce_only_on_descriptor_3() {
13853        probe(super::SpawnRole::SwapCandidate).await;
13854    }
13855}
13856
13857#[cfg(all(test, target_os = "linux"))]
13858mod cgroup_containment_tests {
13859    use super::*;
13860    use cortexkit_test_support::ScratchDir;
13861
13862    fn running(pid: u32) -> bool {
13863        // An orphan can remain a zombie until the container init reaps it.
13864        std::fs::read_to_string(format!("/proc/{pid}/stat"))
13865            .ok()
13866            .and_then(|stat| {
13867                stat.rsplit_once(") ")
13868                    .map(|(_, rest)| rest.starts_with('Z'))
13869            })
13870            .is_some_and(|zombie| !zombie)
13871    }
13872
13873    #[tokio::test]
13874    async fn linux_teardown_reaps_the_grandchild() {
13875        teardown_tree("linux_teardown_reaps_the_grandchild", false).await;
13876    }
13877
13878    #[tokio::test]
13879    async fn linux_shutdown_straggler_reaps_the_grandchild() {
13880        teardown_tree("linux_shutdown_straggler_reaps_the_grandchild", true).await;
13881    }
13882
13883    async fn teardown_tree(test_name: &str, shutdown: bool) {
13884        let dir = ScratchDir::new(test_name);
13885        let root = PathBuf::from(format!(
13886            "/sys/fs/cgroup/subc-tree-test-{test_name}-{}-{}",
13887            std::process::id(),
13888            unix_ms_now()
13889        ));
13890        if let Err(error) = std::fs::create_dir(&root) {
13891            assert!(
13892                std::env::var_os("SUBC_REQUIRE_CGROUP_TEST").is_none(),
13893                "required cgroup test cannot execute: {error}"
13894            );
13895            eprintln!(
13896                "SKIP {test_name}: no writable delegated cgroup at {}: {error}",
13897                root.display()
13898            );
13899            return;
13900        }
13901        let placement = subc_cgroup::prepare_at(&root)
13902            .expect("prepare isolated kernel cgroup")
13903            .expect("isolated cgroup is delegated");
13904        let module_id = "tree-teardown";
13905        let module = placement
13906            .module_path(module_id)
13907            .expect("create isolated module cgroup");
13908        if !module.join("cgroup.kill").exists() {
13909            std::fs::remove_dir(&module).unwrap();
13910            std::fs::remove_dir(root.join("subc-modules")).unwrap();
13911            std::fs::remove_dir(&root).unwrap();
13912            assert!(
13913                std::env::var_os("SUBC_REQUIRE_CGROUP_TEST").is_none(),
13914                "required cgroup.kill interface unavailable"
13915            );
13916            eprintln!("SKIP {test_name}: cgroup.kill unavailable (kernel < 5.14)");
13917            return;
13918        }
13919        let supervisor = Supervisor::new_for_test(
13920            Arc::new(Registry::default()),
13921            RestartPolicy::new(3, Duration::ZERO),
13922        )
13923        .with_cgroup_placement(Some(placement));
13924        let mut runtime = supervisor.runtime_config();
13925        runtime.child_roster = runtime
13926            .child_roster
13927            .for_module(Arc::new(Mutex::new(Duration::from_millis(100))));
13928        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
13929        let pid_file = dir.join("grandchild.pid");
13930        let spec = ModuleSpec {
13931            module_id: module_id.to_string(),
13932            program: PathBuf::from("/bin/sh"),
13933            args: vec![
13934                "-c".into(),
13935                "trap '' TERM; sleep 600 & echo $! > \"$1\"; wait".into(),
13936                "fixture".into(),
13937                pid_file.display().to_string(),
13938            ],
13939            env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
13940                .into_iter()
13941                .map(|key| (key.to_string(), dir.display().to_string()))
13942                .collect(),
13943            reserved: false,
13944            reserved_prefixes: Vec::new(),
13945            protocol: ModuleProtocol::None,
13946            overlap: Default::default(),
13947        };
13948        let child =
13949            spawn_and_mark_running(&spec, &runtime, &snapshot).expect("spawn supervised tree");
13950        let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
13951        let grandchild: u32 = loop {
13952            if let Ok(contents) = std::fs::read_to_string(&pid_file) {
13953                if let Ok(pid) = contents.trim().parse() {
13954                    break pid;
13955                }
13956            }
13957            assert!(
13958                tokio::time::Instant::now() < deadline,
13959                "grandchild pid was not recorded"
13960            );
13961            tokio::time::sleep(Duration::from_millis(10)).await;
13962        };
13963        assert!(
13964            running(grandchild),
13965            "grandchild must be alive before teardown"
13966        );
13967        if shutdown {
13968            let mut child = child;
13969            crate::child_roster::end_children_for_daemon_shutdown(
13970                &runtime.child_roster,
13971                false,
13972                std::future::pending(),
13973            )
13974            .await;
13975            child.wait().await.expect("reap shutdown straggler");
13976        } else {
13977            drain_child_to_state(
13978                module_id,
13979                ModuleProtocol::None,
13980                StopNotice::NotSent,
13981                &Registry::default(),
13982                None,
13983                &snapshot,
13984                &runtime.terminal_ring,
13985                &SpawnEventFeed::default(),
13986                child,
13987                Duration::from_millis(100),
13988                ModuleState::Stopped,
13989                Some(false),
13990            )
13991            .await
13992            .expect("real supervisor teardown");
13993        }
13994        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
13995        while running(grandchild) && tokio::time::Instant::now() < deadline {
13996            tokio::time::sleep(Duration::from_millis(10)).await;
13997        }
13998        let survived = running(grandchild);
13999        // Kill a surviving grandchild so a failed test does not leave it behind.
14000        if survived {
14001            let pid = rustix::process::Pid::from_raw(grandchild as i32).unwrap();
14002            let _ = rustix::process::kill_process(pid, rustix::process::Signal::KILL);
14003            tokio::time::sleep(Duration::from_millis(100)).await;
14004        }
14005        if module.exists() {
14006            std::fs::remove_dir(&module).expect("remove empty module cgroup");
14007        }
14008        std::fs::remove_dir(root.join("subc-modules")).unwrap();
14009        std::fs::remove_dir(&root).unwrap();
14010        assert!(
14011            !survived,
14012            "grandchild {grandchild} outlived module teardown"
14013        );
14014        eprintln!("EXECUTED {test_name}: grandchild {grandchild} killed in isolated cgroup");
14015    }
14016}