Skip to main content

subc_daemon/
supervise.rs

1use std::{
2    collections::{HashMap, HashSet, VecDeque},
3    error::Error,
4    fmt, io,
5    path::PathBuf,
6    process::{ExitStatus, Stdio},
7    sync::{Arc, Mutex, OnceLock},
8    time::{Duration, SystemTime, UNIX_EPOCH},
9};
10
11use cortexkit_log::Retention;
12use serde_json::Value;
13use subc_control::{
14    ClientControlPush, LiveSpawn, ModuleProtocol, RouteCloseReason, SpawnCursor, SpawnEvent,
15    SpawnEventKind, SpawnSnapshot, SupervisorHealthStatus, TerminalDisposition, TerminalExitKind,
16};
17use subc_protocol::{
18    manifest::{SelfSignalKind, SignalAnchor},
19    session::{
20        HealthReport, HealthStatus, ModuleControlCommand, ModuleControlRequest,
21        MODULE_CONTROL_OP_HEALTH_CHECK,
22    },
23    Flags, FrameType, Priority, SUBC_LAUNCH_NONCE_ENV, SUBC_MODULE_ID_ENV,
24};
25use tokio::{
26    process::{Child, Command},
27    sync::{mpsc, oneshot, watch, Mutex as AsyncMutex},
28    task::JoinHandle,
29    time::{sleep, sleep_until, timeout, timeout_at, Instant},
30};
31use tracing::{debug, error, info, warn};
32
33use crate::{
34    child_roster::ChildRoster,
35    daemon_config::{
36        CAPTURE_KEEP_ENV, CAPTURE_MAX_AGE_DAYS_ENV, CAPTURE_MAX_FILE_MB_ENV, CK_LOG_ENV,
37    },
38    forwarding::{
39        CloseReason, ForwardingError, ForwardingTable, GoodbyeTarget, ModuleControlRpcOutcome,
40        ModuleDrainTarget, PendingModuleControlRpc,
41    },
42    provenance::{ExecutableIdentityProbe, SpawnedFileIdentity},
43    registry::{ConnectionId, RegistrationEndReason, RegistryError},
44    stderr_tail::{
45        pump_stderr_to, pump_stdout_to, ChildOutputSink, StderrRing, StderrTailConfig,
46        StderrTailSnapshot,
47    },
48    terminal_ring::{TerminalHistorySnapshot, TerminalRecord, TerminalRing, TerminalRingConfig},
49    Frame, FrameSink, Registry,
50};
51
52#[cfg(not(windows))]
53use crate::provenance::spawned_file_identity;
54
55#[path = "supervise_swap.rs"]
56mod swap;
57
58/// Command-line flag used by supervised modules to find subc.
59///
60/// subc launches module-mode children as `<module> --subc <connection-file-path>`.
61/// The path points at the TCP+key connection file; it is not an ambient signal and
62/// is never inherited by standalone children.
63pub const SUBC_ARG: &str = "--subc";
64
65const DEFAULT_MAX_RESTARTS: u32 = 3;
66const DEFAULT_BACKOFF: Duration = Duration::from_millis(100);
67const DEFAULT_MAX_BACKOFF: Duration = Duration::from_secs(30);
68/// The span `DEFAULT_MAX_RESTARTS` is counted over. Ten minutes is long enough
69/// to contain a real crash loop (which respawns in seconds) and short enough
70/// that unrelated crashes hours apart never accumulate into a permanent stop.
71const DEFAULT_RESTART_WINDOW: Duration = Duration::from_secs(600);
72/// How long a drain waits for already-dispatched requests to finalize before
73/// the child is torn down. Sized for TOOL-SCALE work (bash, inspect, builds),
74/// not RPC-scale: the original 2s value silently cut nearly every real tool
75/// call at the fence, making the wait-for-finalize design decorative for the
76/// workloads it existed for. Quiescence short-circuits, so an idle module
77/// restarts immediately regardless of this value; the budget is spent only
78/// when a genuine in-flight request is worth finishing. Per-module override:
79/// `drain_timeout_ms` in subc.jsonc; per-restart override: the operator's
80/// `supervisor.restart{drain_timeout_ms}` (0 = cut now, for wedge bounces
81/// where a stuck request will never settle).
82pub const DEFAULT_DRAIN_TIMEOUT: Duration = Duration::from_secs(30);
83const REGISTRY_RELEASE_TIMEOUT: Duration = Duration::from_secs(1);
84const REGISTRY_RELEASE_POLL: Duration = Duration::from_millis(10);
85/// How long a restart waits for the exited process's output readers.
86///
87/// The restart does not depend on either output reader finishing. A stderr
88/// reader still running at this bound is left running, and its later output goes
89/// into the exited process's own section of the stderr ring (see
90/// `StderrRing::push_line_from`), ending naturally at EOF on its pipe. So the
91/// bound no longer decides whether a crash's last lines are kept: under load
92/// the reader may simply not have been scheduled yet, and cutting it there
93/// discarded exactly the lines that explained the crash.
94///
95/// What the bound still decides is when the tail starts reporting
96/// `Incomplete`: a pipe open past it usually means a descendant of the exited
97/// process still holds it, and the tail cannot claim to be whole until that
98/// pipe closes. That is also the only case in which the wait costs the restart
99/// anything, because a pipe with no other holder reaches EOF when the process
100/// exits. Under load a slow reader can show `Incomplete` briefly; it returns to
101/// `Captured` at EOF with nothing lost.
102///
103/// The stdout reader carries no ring, only the capture file. It also continues
104/// to EOF after this bound: child exit closes the writer but does not mean the
105/// reader has consumed all buffered bytes. Late output from either pipe can
106/// therefore reach the shared capture file after the successor starts.
107const STDERR_PUMP_DRAIN_TIMEOUT: Duration = Duration::from_millis(250);
108/// Maximum number of supervised process spawn/exit facts retained per daemon incarnation.
109pub const SPAWN_EVENT_RING_CAPACITY: usize = 4096;
110const SPAWN_SUBSCRIBER_BUFFER: usize = SPAWN_EVENT_RING_CAPACITY + 1;
111/// Terminal Error code for a `supervisor.spawn_subscribe` stream the daemon
112/// dropped because the subscriber stopped draining its frames. The detail's
113/// `first_undelivered_cursor` names the first event it did not receive; the
114/// client resubscribes from the last cursor it did receive.
115pub(crate) const SPAWN_SUBSCRIBER_LAGGED_CODE: &str = "spawn_subscriber_lagged";
116
117#[cfg(target_os = "macos")]
118struct PrivacyExec {
119    reader: tokio::io::unix::AsyncFd<std::io::PipeReader>,
120    deadline: tokio::time::Instant,
121    expected: Option<subc_os::FileIdentity>,
122    trampoline: Option<subc_os::FileIdentity>,
123    script: bool,
124    module_id: String,
125}
126
127#[cfg(all(test, target_os = "macos"))]
128pub(crate) fn test_privacy_trampoline() -> PathBuf {
129    // Cargo's unit-test executable lives in <profile>/deps; its fixture bin
130    // lives beside that directory. This honors custom CARGO_TARGET_DIR too.
131    let path = std::env::current_exe()
132        .unwrap()
133        .parent()
134        .unwrap()
135        .parent()
136        .unwrap()
137        .join("privacy-trampoline-fixture");
138    // Without the fixture every macOS spawn is refused, and the tests that
139    // spawn fail later as a module in state Failed, which names the wrong
140    // cause. `cargo test -p subc-daemon --lib` alone does not build it.
141    assert!(
142        path.exists(),
143        "privacy-trampoline-fixture not built at {}: run `cargo build -p subc-daemon \
144         --bins --features test-support` or `cargo test -p subc-daemon` first",
145        path.display()
146    );
147    path
148}
149
150#[cfg(target_os = "macos")]
151fn probe_privacy_trampoline(path: &std::path::Path) -> Result<(), String> {
152    use std::io::Read;
153    let mut probe = std::process::Command::new(path)
154        .args(["__disclaim-exec", "--probe"])
155        .stdin(Stdio::null())
156        .stdout(Stdio::piped())
157        .stderr(Stdio::piped())
158        .spawn()
159        .map_err(|error| {
160            format!(
161                "privacy trampoline probe failed for {}: {error}",
162                path.display()
163            )
164        })?;
165    let deadline = std::time::Instant::now() + Duration::from_secs(5);
166    let status = loop {
167        match probe.try_wait() {
168            Ok(Some(status)) => break status,
169            Ok(None) if std::time::Instant::now() < deadline => {
170                std::thread::sleep(Duration::from_millis(5))
171            }
172            result => {
173                let _ = probe.kill();
174                let _ = probe.wait();
175                return Err(format!(
176                    "privacy trampoline probe failed or timed out for {}: {result:?}",
177                    path.display()
178                ));
179            }
180        }
181    };
182    let mut answer = String::new();
183    if let Some(stdout) = probe.stdout.take() {
184        let _ = stdout.take(256).read_to_string(&mut answer);
185    }
186    if status.success() && answer.trim() == subc_os::privacy_identity::TRAMPOLINE_PROBE {
187        return Ok(());
188    }
189    let mut diagnostic = String::new();
190    if let Some(stderr) = probe.stderr.take() {
191        let _ = stderr.take(1024).read_to_string(&mut diagnostic);
192    }
193    let cause = diagnostic
194        .trim()
195        .strip_prefix("ck-subc: own privacy identity refused: ")
196        .unwrap_or("binary does not implement the privacy trampoline protocol");
197    Err(format!(
198        "{cause}: probe of {} exited {status}",
199        path.display()
200    ))
201}
202
203#[cfg(target_os = "macos")]
204fn privacy_command(
205    spec: &ModuleSpec,
206    roster: &ChildRoster,
207) -> Result<
208    (
209        Command,
210        Option<PrivacyExec>,
211        subc_os::privacy_identity::ExecAcknowledgement,
212    ),
213    SuperviseError,
214> {
215    let failure = |cause: String| {
216        warn!(module_id = %spec.module_id, %cause, "privacy identity trampoline refused module spawn");
217        SuperviseError::Spawn {
218            program: spec.program.clone(),
219            source: io::Error::other(cause),
220            cgroup_path: None,
221        }
222    };
223    let trampoline = roster.privacy_trampoline().map_err(failure)?;
224    // Resolve PATH with the same environment the Command will receive. For
225    // scripts retain the existing orphan-identity rule: the kernel chooses
226    // the interpreter, and its observed image is the one recorded. Do not
227    // duplicate the kernel's shebang/PATH interpreter resolution in Rust.
228    let program = if spec.program.components().count() == 1 && !spec.program.is_absolute() {
229        let path = spec
230            .env
231            .iter()
232            .find(|(key, _)| key == "PATH")
233            .map(|(_, value)| std::ffi::OsString::from(value))
234            .or_else(|| std::env::var_os("PATH"))
235            .unwrap_or_else(|| "/usr/bin:/bin".into());
236        std::env::split_paths(&path)
237            .map(|dir| dir.join(&spec.program))
238            .find(|path| path.is_file())
239            .unwrap_or_else(|| spec.program.clone())
240    } else {
241        spec.program.clone()
242    };
243    let expected = subc_os::file_identity(&program);
244    let trampoline_image = subc_os::file_identity(&trampoline);
245    let script = {
246        use std::io::Read;
247        let mut prefix = [0u8; 2];
248        std::fs::File::open(&program)
249            .is_ok_and(|mut file| file.read_exact(&mut prefix).is_ok() && &prefix == b"#!")
250    };
251    if expected.is_none() || expected == subc_os::file_identity(&trampoline) {
252        return Err(failure(
253            "privacy identity module executable is missing or is the trampoline itself".to_string(),
254        ));
255    }
256    let (reader, ack) = subc_os::privacy_identity::ExecAcknowledgement::pipe()
257        .map_err(|error| failure(error.to_string()))?;
258    let reader =
259        tokio::io::unix::AsyncFd::new(reader).map_err(|error| failure(error.to_string()))?;
260    let mut command = Command::new(&trampoline);
261    command
262        .arg("__disclaim-exec")
263        .arg(ack.fd().to_string())
264        .arg(&program);
265    ack.install(command.as_std_mut());
266    Ok((
267        command,
268        Some(PrivacyExec {
269            reader,
270            deadline: tokio::time::Instant::now() + Duration::from_secs(5),
271            expected,
272            trampoline: trampoline_image,
273            script,
274            module_id: spec.module_id.clone(),
275        }),
276        ack,
277    ))
278}
279
280struct SupervisedChild {
281    child: Child,
282    #[cfg(windows)]
283    nonce_delivery: Option<subc_os::LaunchNoncePipeDelivery>,
284    #[cfg(target_os = "macos")]
285    privacy_exec: Option<PrivacyExec>,
286    /// Set once this launch's exec acknowledgement confirms the module image.
287    /// On macOS the pid first runs the `ck-subc` launch trampoline (see
288    /// `subc_os::privacy_identity`), which then replaces itself with the
289    /// module. The supervisor owns and can kill that pid from spawn, but
290    /// status readers report it only after this latch is set, so nothing
291    /// reports the trampoline's image as the module's.
292    #[cfg(target_os = "macos")]
293    report_ready: Arc<OnceLock<()>>,
294    /// Refusal before the module image was accepted, retained for terminal records.
295    spawn_failure: Option<String>,
296    /// The protocol this process was launched with. A reload can store a new
297    /// launch spec with a different protocol, but that takes effect only at the
298    /// next spawn, so this process keeps being handled by the protocol it
299    /// actually speaks.
300    protocol: ModuleProtocol,
301    /// This process's cgroup name: a bounded module/slot label followed by a
302    /// spawn suffix unique to this process (when cgroup placement is on). A
303    /// retired process in a slot may still be draining when a later one is
304    /// spawned into that slot, so the suffix keeps the later process out of
305    /// the retired one's cgroup, which is the domain a kill applies to.
306    #[cfg(target_os = "linux")]
307    module_id: String,
308    #[cfg(target_os = "linux")]
309    cgroup_placement: Option<subc_cgroup::Placement>,
310    /// The job that contains this child and every process it spawns (issue #109).
311    ///
312    /// Dropping this handle is what reaps a surviving tree when no supervisor
313    /// code runs — a daemon crash — because the job carries
314    /// `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`.
315    ///
316    /// That limit is not crash-only, and the difference is worth knowing: a
317    /// Windows daemon *stop* is `taskkill` or the scheduler's `/End` — the
318    /// SIGTERM handler is `#[cfg(unix)]` — so the daemon dies with no stop
319    /// notice and the kernel closes the job handle, `TerminateProcess`ing every
320    /// module at once. Before this change they survived that, saw EOF on the
321    /// control socket, and ran their own teardown; Unix keeps that path
322    /// deliberately, so a module can seal a WAL or close a capture rather than
323    /// be killed mid-write. So this trades graceful teardown on every Windows
324    /// daemon stop for containment on a crash, which is the right way round
325    /// today: orphaned GPU workers are a reported, recurring problem, and the
326    /// modules that write most heavily do not run on Windows.
327    ///
328    /// The fix is a real Windows stop path — the daemon draining before it
329    /// exits, the twin of the Unix SIGTERM handler. Once it exists, this limit
330    /// reaches only what the drain left behind, which is what it should reach.
331    #[cfg(windows)]
332    job: Option<subc_jobobject::JobObject>,
333    stdout_pump: Option<JoinHandle<()>>,
334    stderr_pump: Option<StderrPump>,
335    stderr_ring: Arc<Mutex<StderrRing>>,
336    spawned_at_ms: u64,
337    spawned_from: PathBuf,
338    spawned_file_identity: Option<SpawnedFileIdentity>,
339    process_start_time: Option<u64>,
340    process_identity: Option<ProcessIdentity>,
341    pid: u32,
342    /// This process's entry in the daemon's child roster, released when the
343    /// process is reaped or this handle is dropped.
344    roster_guard: Option<crate::child_roster::RosterGuard>,
345}
346
347impl SupervisedChild {
348    fn id(&self) -> Option<u32> {
349        Some(self.pid)
350    }
351
352    fn process_identity(&self) -> Option<ProcessIdentity> {
353        self.process_identity
354    }
355
356    async fn wait(&mut self) -> io::Result<ExitStatus> {
357        #[cfg(target_os = "macos")]
358        self.confirm_privacy_exec().await;
359        // The roster entry is NOT released here. A daemon shutdown waits for the
360        // roster to empty and then exits the process, so releasing at the reap
361        // let it exit before the exit handler wrote this child's terminal record
362        // (the stderr drain and snapshot update sit in between), and the
363        // shutdown's own `daemon_shutdown` record was intermittently lost. The
364        // caller releases it after recording the exit (`release_roster`), and
365        // dropping the handle releases it too.
366        let result = self.child.wait().await;
367        #[cfg(target_os = "linux")]
368        if result.is_ok() {
369            if let Some(placement) = self.cgroup_placement.as_ref() {
370                cleanup_reaped_cgroup(placement, &self.module_id).await;
371                // Keep ownership while awaiting kernel population changes: a
372                // drain timeout may cancel this wait and then escalate/reap.
373                self.cgroup_placement = None;
374            }
375        }
376        result
377    }
378
379    #[cfg(target_os = "macos")]
380    async fn confirm_privacy_exec(&mut self) {
381        let Some(pending) = &mut self.privacy_exec else {
382            return;
383        };
384        let result = tokio::time::timeout_at(pending.deadline, async {
385            let mut record = Vec::new();
386            loop {
387                let mut ready = pending.reader.readable().await?;
388                let read = ready.try_io(|reader| {
389                    use std::io::Read;
390                    let mut reader = reader.get_ref();
391                    let mut buffer = [0u8; 256];
392                    reader.read(&mut buffer).map(|count| (count, buffer))
393                });
394                match read {
395                    Ok(Ok((0, _))) => return Ok::<_, io::Error>(record),
396                    Ok(Ok((count, buffer))) => {
397                        if record.len() + count > 1024 {
398                            return Err(io::Error::other(
399                                "privacy exec refusal record is too long",
400                            ));
401                        }
402                        record.extend_from_slice(&buffer[..count]);
403                    }
404                    Ok(Err(error)) => return Err(error),
405                    Err(_) => continue,
406                }
407            }
408        })
409        .await;
410        // Keep the reader in self across await: select cancellation must not
411        // discard the handshake or reset its original five-second deadline.
412        let pending = self.privacy_exec.as_ref().expect("pending exec");
413        let cause = match result {
414            Err(_) => Some("privacy identity trampoline did not exec within 5s".to_string()),
415            Ok(Err(error)) => Some(format!(
416                "privacy identity exec acknowledgement failed: {error}"
417            )),
418            Ok(Ok(record)) if !record.is_empty() => Some(
419                std::str::from_utf8(&record)
420                    .ok()
421                    .and_then(|record| {
422                        record
423                            .trim()
424                            .strip_prefix(subc_os::privacy_identity::EXEC_REFUSAL_TAG)
425                    })
426                    .filter(|cause| !cause.is_empty())
427                    .unwrap_or("invalid privacy exec refusal record")
428                    .to_string(),
429            ),
430            Ok(Ok(_)) => match self.child.try_wait() {
431                // Empty EOF is the exec acknowledgement. A real module may exit
432                // immediately, including with a reserved trampoline status; no
433                // image is admitted, and its ordinary exit contract stays intact.
434                Ok(Some(_status)) => None,
435                Err(error) => Some(format!("privacy identity trampoline wait failed: {error}")),
436                Ok(None) => {
437                    let image = observe_spawned_image(self.pid);
438                    if let Some(image) = image.filter(|image| {
439                        image.executable.is_some()
440                            && image.executable != pending.trampoline
441                            && (image.executable == pending.expected || pending.script)
442                    }) {
443                        if let Some(guard) = &self.roster_guard {
444                            guard.confirm_executable(image);
445                        }
446                        let _ = self.report_ready.set(());
447                        info!(module_id = %pending.module_id, pid = self.pid,
448                            "module spawned with own privacy identity (responsibility disclaimed)");
449                        None
450                    } else if image.is_none()
451                        || image.is_some_and(|image| image.executable.is_none())
452                    {
453                        // A process can exit between try_wait and the kernel
454                        // image read. Empty EOF already acknowledged exec, so
455                        // preserve that module's ordinary exit rather than
456                        // mislabel a disappearing image as trampoline refusal.
457                        // Keep pending in self across await so cancellation does
458                        // not discard validation or reset its original deadline.
459                        match tokio::time::timeout_at(pending.deadline, self.child.wait()).await {
460                            Ok(Ok(_status)) => None,
461                            Ok(Err(error)) => Some(format!("privacy identity trampoline wait failed: {error}")),
462                            Err(_) => Some("privacy identity module executable remained unreadable until the 5s exec deadline".to_string()),
463                        }
464                    } else {
465                        Some("privacy identity trampoline executable mismatch: expected the module program, not ck-subc".to_string())
466                    }
467                }
468            },
469        };
470        let pending = self.privacy_exec.take().expect("pending exec");
471        if let Some(cause) = cause {
472            warn!(module_id = %pending.module_id, pid = self.pid, %cause, "privacy identity trampoline refused module spawn");
473            self.spawn_failure = Some(cause);
474            // No image is admitted on failure. Reach the entire fresh process
475            // group, including a module which spawned a helper before refusal.
476            if let Some(pid) = rustix::process::Pid::from_raw(self.pid as i32) {
477                let _ = rustix::process::kill_process_group(pid, rustix::process::Signal::KILL);
478            }
479            let _ = self.child.start_kill();
480        }
481    }
482
483    /// Releases this child's daemon-shutdown roster entry once its exit has
484    /// been recorded. The pid is already reaped and free for reuse, so the
485    /// entry must not outlive the record any longer than that.
486    fn release_roster(&mut self) {
487        self.roster_guard = None;
488    }
489
490    /// Kill the child and, where containment is available, its process tree.
491    ///
492    /// `Child::kill` is `TerminateProcess` scoped to one pid, so a module with a
493    /// helper process leaked the helper — the Synapse embedding module's CUDA
494    /// worker holds the GPU allocation, so the leak cost VRAM until the next
495    /// restart of something else. Terminating the job reaches grandchildren that
496    /// a tree walk cannot, including one whose parent has already exited and
497    /// been reparented away.
498    ///
499    /// Best-effort like `request_graceful_stop`: a job failure is logged and the
500    /// direct-child kill still decides the outcome, so containment can never
501    /// change whether a module is reported as stopped.
502    fn start_kill(&mut self) -> io::Result<()> {
503        #[cfg(windows)]
504        if let Some(job) = &self.job {
505            if let Err(error) = job.terminate() {
506                debug!(
507                    error = %error,
508                    "job termination failed; the direct-child kill still owns the outcome"
509                );
510            }
511        }
512        #[cfg(target_os = "linux")]
513        kill_module_cgroup(self.cgroup_placement.as_ref(), &self.module_id);
514        self.child.start_kill()
515    }
516
517    async fn drain_stderr(&mut self, module_id: &str) {
518        if let Some(pump) = self.stdout_pump.take() {
519            settle_stdout_pump(module_id, pump, STDERR_PUMP_DRAIN_TIMEOUT).await;
520        }
521
522        let Some(pump) = self.stderr_pump.take() else {
523            return;
524        };
525        settle_stderr_pump(
526            module_id,
527            &self.stderr_ring,
528            pump,
529            STDERR_PUMP_DRAIN_TIMEOUT,
530        )
531        .await;
532    }
533}
534
535/// Bound the restart's wait, not the lifetime of the exited child's stdout
536/// reader. Slow pipe reads or capture writes must not turn the bound into lost
537/// output; dropping the handle lets the reader finish at EOF, like stderr.
538async fn settle_stdout_pump(module_id: &str, mut pump: JoinHandle<()>, bound: Duration) {
539    match timeout(bound, &mut pump).await {
540        Ok(Ok(())) => {}
541        Ok(Err(error)) => {
542            warn!(module_id, error = %error, "stdout pump ended unexpectedly");
543        }
544        Err(_) => {
545            drop(pump);
546            warn!(
547                module_id,
548                waited = ?bound,
549                "stdout pipe of the exited process has not drained; its reader keeps running without delaying the restart"
550            );
551        }
552    }
553}
554
555/// The reader task for one process's stderr, with the ring generation its
556/// lines are attributed to.
557struct StderrPump {
558    task: JoinHandle<()>,
559    generation: u64,
560}
561
562/// Retire an exited process's stderr reader and wait up to `bound` for it to
563/// reach EOF. A reader still running at the bound is detached, not stopped: it
564/// keeps filling the exited process's section of the ring until its pipe
565/// closes, and the tail reads `Incomplete` until then. See
566/// [`STDERR_PUMP_DRAIN_TIMEOUT`] for why.
567async fn settle_stderr_pump(
568    module_id: &str,
569    ring: &Arc<Mutex<StderrRing>>,
570    pump: StderrPump,
571    bound: Duration,
572) {
573    let lock = || ring.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
574    let StderrPump {
575        mut task,
576        generation,
577    } = pump;
578    lock().retire_pump(generation);
579    match timeout(bound, &mut task).await {
580        Ok(Ok(())) => {}
581        Ok(Err(err)) => {
582            let mut ring = lock();
583            ring.mark_incomplete_from(generation, format!("stderr pump ended unexpectedly: {err}"));
584            ring.finish_pump(generation);
585            warn!(module_id, error = %err, "stderr pump ended before clean EOF");
586        }
587        Err(_) => {
588            // Dropping the handle detaches the task; it ends at EOF on its pipe.
589            drop(task);
590            lock().mark_pump_late(
591                generation,
592                format!(
593                    "stderr of the exited process had not reached EOF {bound:?} after it was \
594                     retired (a descendant may still hold the pipe open); lines it still \
595                     writes are kept in that process's section"
596                ),
597            );
598            warn!(
599                module_id,
600                waited = ?bound,
601                "stderr pipe of the exited process is still open; its reader keeps running without delaying the restart"
602            );
603        }
604    }
605}
606
607fn registration_release_events() -> &'static watch::Sender<u64> {
608    static EVENTS: OnceLock<watch::Sender<u64>> = OnceLock::new();
609    EVENTS.get_or_init(|| {
610        let (sender, _receiver) = watch::channel(0);
611        sender
612    })
613}
614
615pub(crate) fn notify_registration_release() {
616    let events = registration_release_events();
617    let next_generation = (*events.borrow()).wrapping_add(1);
618    events.send_replace(next_generation);
619}
620
621/// How to launch one singleton module process.
622#[derive(Debug, Clone, PartialEq, Eq)]
623pub struct ModuleSpec {
624    pub module_id: String,
625    pub program: PathBuf,
626    pub args: Vec<String>,
627    pub env: Vec<(String, String)>,
628    /// When true this is a reserved module: each spawn gets a fresh one-time launch
629    /// nonce that the child must echo in its HELLO, so only the daemon-spawned
630    /// process can register this module_id (a security-boundary module like the
631    /// credential vault must not be impersonable while it is down/restarting).
632    pub reserved: bool,
633    /// Module-id prefixes this supervised module owns for reserved HELLO checks.
634    /// Prefixes come from daemon config and must end in `:` before they reach the
635    /// supervisor; the owner module's current spawn nonce authorizes claims under
636    /// each prefix.
637    pub reserved_prefixes: Vec<String>,
638    /// The wire protocol this module speaks, as DECLARED in daemon config.
639    ///
640    /// [`ModuleProtocol::None`] changes five things and nothing else: wire health
641    /// probing is suppressed (an optional HTTP probe can replace it), teardown sends SIGTERM before waiting,
642    /// `route.open` is refused, the spawn passes NO `--subc <path>` argument
643    /// and NO launch nonce, and a clean exit the daemon did not request is
644    /// restarted as a crash rather than recorded as a stop (see `on_child_exit`:
645    /// a stock program exits 0 on a stray SIGTERM, and a stop would leave it
646    /// down for good). `SUBC_MODULE_ID` still goes into the environment,
647    /// because a process ignores an environment variable it does not read.
648    ///
649    /// The argument is the part that cannot be "harmless to a process that
650    /// ignores it": a stock binary exits on an unknown flag before it listens
651    /// (`nats-server`: "flag provided but not defined: -subc"), which is how the
652    /// first conformance run against this mode found it. The nonce is withheld
653    /// because a process that will never present it gains nothing from holding
654    /// it, and a secret in the environment of a process that does not need it is
655    /// a leak surface for no benefit.
656    pub protocol: ModuleProtocol,
657    /// Whether two processes of this module may run at once, which is what a
658    /// blue/green swap does for the length of its overlap. Declared in daemon
659    /// config because the daemon must be able to answer it while the module is
660    /// down, and so a module cannot talk itself into it after registering.
661    pub overlap: ModuleOverlap,
662}
663
664/// Whether a module tolerates a second process of itself running alongside.
665///
666/// Most modules are single-writer on their store (a WAL, a capture log, a
667/// resident index behind a writer barrier), and two processes on one store
668/// corrupt it. So a swap, which overlaps the old and new process by design,
669/// is refused unless the module's config opts in with `overlap: "safe"`.
670#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
671pub enum ModuleOverlap {
672    /// Never run two processes of this module at once. The default.
673    #[default]
674    Exclusive,
675    /// The module has said a second process of itself is harmless for the
676    /// length of a swap.
677    ///
678    /// Declare it only if a second instance can run for a few seconds without
679    /// touching ANY single-writer store: every database, WAL, index, projector
680    /// and scheduled job the module owns. A lease on part of that state is not
681    /// enough. broca's session lease guards WAL appends while its run index, its
682    /// store projector and its archive fold timer (which unlinks live WAL files)
683    /// stay single-writer, so broca is exclusive despite holding a lease. The
684    /// refusal only fires after this has been decided, so the decision is the
685    /// check.
686    Safe,
687}
688
689impl ModuleOverlap {
690    pub fn as_str(self) -> &'static str {
691        match self {
692            Self::Exclusive => "exclusive",
693            Self::Safe => "safe",
694        }
695    }
696}
697
698/// Environment variable telling a spawned module which case it was started
699/// for, before it sends HELLO. Only a swap candidate carries it, as
700/// [`SPAWN_ROLE_SWAP_CANDIDATE`]; every other spawn has it removed.
701///
702/// It chooses a warm-up budget, nothing else: a swap candidate can warm for
703/// longer because nobody waits on it, while a plain restart must flip ready
704/// quickly because callers see `module_warming` until it does. Absence means
705/// plain restart, the safe reading. The daemon trusts nothing about it; the
706/// candidate is proven by its launch nonce at HELLO.
707pub const SUBC_SPAWN_ROLE_ENV: &str = "SUBC_SPAWN_ROLE";
708/// The one value of [`SUBC_SPAWN_ROLE_ENV`] the daemon sets.
709pub const SPAWN_ROLE_SWAP_CANDIDATE: &str = "swap_candidate";
710/// How long a swap waits for its candidate to register and declare itself
711/// ready when the operator does not say. A module warming as a swap candidate
712/// may take up to 90 s (aft's ceiling, the largest in the fleet), so the
713/// daemon allows that plus time to start the process and send HELLO.
714pub const DEFAULT_SWAP_READY_TIMEOUT: Duration = Duration::from_secs(100);
715
716/// Bounded restart policy for crash exits.
717///
718/// `max_restarts` is the number of replacement processes allowed after the
719/// initial spawn WITHIN `window`. After that many crash restarts inside one
720/// window the module enters [`ModuleState::Failed`] and the supervisor stops
721/// the crash loop.
722///
723/// The budget is a RATE, not a lifetime total. It used to be a lifetime total,
724/// and that only survived because crashes were rare: a module that crashed
725/// three times across a week was disabled forever by crashes that had nothing
726/// to do with each other. That stopped being survivable once modules began
727/// exiting non-zero whenever the daemon's connection to them drops, because
728/// then every daemon-side connection drop spends a unit of the same budget and
729/// one flappy hour permanently stops a healthy module. Restarts older than
730/// `window` release their slot, so a module that crashed twice yesterday has a
731/// full budget today, while a genuine crash loop -- which is fast by
732/// definition -- still reaches the cap and stops.
733#[derive(Debug, Clone, Copy, PartialEq, Eq)]
734pub struct RestartPolicy {
735    pub max_restarts: u32,
736    /// Base delay before a crash replacement. The actual delay escalates with
737    /// the number of recent crash replacements and is capped by `max_backoff`.
738    pub backoff: Duration,
739    /// Maximum delay before a crash replacement.
740    pub max_backoff: Duration,
741    /// The span `max_restarts` is counted over. `Duration::ZERO` makes the
742    /// budget effectively infinite (nothing is ever in-window), which is why
743    /// daemon config refuses `window_secs: 0` rather than quietly accepting it.
744    pub window: Duration,
745}
746
747impl RestartPolicy {
748    /// A policy with the default crash window. Callers that care about the
749    /// window say so with [`Self::with_window`]; the ones that do not are
750    /// asking for the standard rate limit, not for no limit.
751    pub fn new(max_restarts: u32, backoff: Duration) -> Self {
752        Self {
753            max_restarts,
754            backoff,
755            max_backoff: DEFAULT_MAX_BACKOFF,
756            window: DEFAULT_RESTART_WINDOW,
757        }
758    }
759
760    pub fn with_max_backoff(mut self, max_backoff: Duration) -> Self {
761        self.max_backoff = max_backoff;
762        self
763    }
764
765    pub fn with_window(mut self, window: Duration) -> Self {
766        self.window = window;
767        self
768    }
769
770    /// Calculate the capped exponential delay for the next crash replacement.
771    /// `restart_in_window` is zero for the first replacement after an operator
772    /// action (restart, reload, re-enable) cleared the crash ring, or after all
773    /// older crash replacements have aged out of the window.
774    fn delay_for_restart(&self, restart_in_window: u32) -> Duration {
775        if self.backoff.is_zero() || self.max_backoff.is_zero() {
776            return Duration::ZERO;
777        }
778
779        let mut delay = self.backoff;
780        for _ in 0..restart_in_window {
781            if delay >= self.max_backoff {
782                return self.max_backoff;
783            }
784            delay = delay
785                .checked_mul(10)
786                .unwrap_or(self.max_backoff)
787                .min(self.max_backoff);
788        }
789        delay.min(self.max_backoff)
790    }
791
792    /// The one sentence that explains a budget-exhausted stop, used for both the
793    /// log line and the terminal record so the two cannot drift. It names the
794    /// window because `max_restarts=3` alone reads as a lifetime cap, which is
795    /// exactly what this budget is not.
796    fn budget_exhausted_detail(&self) -> String {
797        format!(
798            "crash budget exhausted: max_restarts={} within window_secs={}",
799            self.max_restarts,
800            self.window.as_secs()
801        )
802    }
803}
804
805impl Default for RestartPolicy {
806    fn default() -> Self {
807        Self {
808            max_restarts: DEFAULT_MAX_RESTARTS,
809            backoff: DEFAULT_BACKOFF,
810            max_backoff: DEFAULT_MAX_BACKOFF,
811            window: DEFAULT_RESTART_WINDOW,
812        }
813    }
814}
815
816#[derive(Debug, Clone, Copy, PartialEq, Eq)]
817struct CrashRestartSchedule {
818    restart_in_window: u32,
819    delay: Duration,
820}
821
822/// Whether the daemon itself will bring this module back after the exit being
823/// handled: it is enabled AND its in-window crash restarts are below the cap.
824///
825/// Takes `&mut` because reading the budget prunes it. Instants that fell out of
826/// the window are dropped here rather than by a timer, so the count is right
827/// the moment somebody asks and no bookkeeping runs for idle modules.
828fn daemon_will_restart(
829    state: &mut SupervisorSnapshot,
830    policy: &RestartPolicy,
831    now: Instant,
832) -> bool {
833    state.enabled && state.crash_restarts_in_window(policy.window, now) < policy.max_restarts
834}
835
836const DEFAULT_HEALTH_CADENCE: Duration = Duration::from_secs(30);
837const DEFAULT_HEALTH_DEADLINE: Duration = Duration::from_secs(5);
838const DEFAULT_HEALTH_FAILURE_THRESHOLD: u32 = 3;
839const MAX_HEALTH_METRICS_BYTES: usize = 16 * 1024;
840
841#[derive(Debug, Clone, Copy, PartialEq, Eq)]
842pub enum HealthAction {
843    Report,
844    Restart,
845    Alert,
846}
847
848impl fmt::Display for HealthAction {
849    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
850        f.write_str(match self {
851            Self::Report => "report",
852            Self::Restart => "restart",
853            Self::Alert => "alert",
854        })
855    }
856}
857
858#[derive(Debug, Clone, PartialEq, Eq)]
859pub struct HealthConfig {
860    /// Optional loopback HTTP endpoint for a managed non-wire process.
861    /// Changing it applies live on rescan; the process protocol changes only
862    /// at its next spawn.
863    pub http: Option<String>,
864    pub cadence: Duration,
865    pub deadline: Duration,
866    pub failure_threshold: u32,
867    pub on_degraded: HealthAction,
868    pub on_failing: HealthAction,
869    pub critical: bool,
870}
871
872impl Default for HealthConfig {
873    fn default() -> Self {
874        Self {
875            http: None,
876            cadence: DEFAULT_HEALTH_CADENCE,
877            deadline: DEFAULT_HEALTH_DEADLINE,
878            failure_threshold: DEFAULT_HEALTH_FAILURE_THRESHOLD,
879            on_degraded: HealthAction::Report,
880            on_failing: HealthAction::Report,
881            critical: false,
882        }
883    }
884}
885
886/// The supervisor's view of one module's health, relayed to clients over
887/// channel-0 and rendered by `ck health`.
888///
889/// THIS TYPE IS WHERE THE ABSENCE MEANINGS ARE CREATED, which is why they are
890/// stated here rather than only at the wire type a consumer reads. A reader can
891/// look up what `None` means; only a writer can silently change it, and the
892/// writer has no reason to go looking at a downstream contract before editing.
893///
894/// `last_probe_ms: None` MEANS NEVER PROBED, not probed-long-ago. It is cleared
895/// back to `None` on re-registration precisely so a respawned module does not
896/// carry its predecessor's timestamp — so an old value and an absent one call for
897/// opposite readings, and anything that defaulted this to a number would make a
898/// never-probed module indistinguishable from one probed at the epoch.
899///
900/// `detail` and `metrics` are `None` when the module published none on this
901/// probe, which does not mean it reported nothing wrong — it is also the shape
902/// when the probe never reached it. `last_probe_ms` is what separates those.
903#[derive(Debug, Clone, PartialEq)]
904pub struct ModuleHealthStatus {
905    pub status: SupervisorHealthStatus,
906    pub last_probe_ms: Option<u64>,
907    pub detail: Option<String>,
908    pub metrics: Option<Value>,
909    pub consecutive_failures: u32,
910    /// Number of replies received after a recurring health probe's deadline.
911    /// Unlike a timeout, every increment proves the module was alive.
912    pub late_answer_count: u64,
913    /// End-to-end latency of the newest late reply, measured from probe start.
914    pub last_late_answer_latency_ms: Option<u64>,
915    pub last_action: Option<String>,
916    /// Set together with `last_action`; the pair moves as one, and both being
917    /// absent means no escalation has ever been taken rather than that the last
918    /// one succeeded.
919    pub last_action_ms: Option<u64>,
920}
921
922impl Default for ModuleHealthStatus {
923    fn default() -> Self {
924        Self {
925            status: SupervisorHealthStatus::Unknown,
926            last_probe_ms: None,
927            detail: None,
928            metrics: None,
929            consecutive_failures: 0,
930            late_answer_count: 0,
931            last_late_answer_latency_ms: None,
932            last_action: None,
933            last_action_ms: None,
934        }
935    }
936}
937
938/// Typed lifecycle state for a supervised module.
939#[derive(Debug, Clone, Copy, PartialEq, Eq)]
940pub enum ModuleState {
941    Starting,
942    Running,
943    Unresponsive,
944    Restarting,
945    Draining,
946    Stopped,
947    Failed,
948    Disabled,
949}
950
951impl fmt::Display for ModuleState {
952    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
953        f.write_str(match self {
954            Self::Starting => "starting",
955            Self::Running => "running",
956            Self::Unresponsive => "unresponsive",
957            Self::Restarting => "restarting",
958            Self::Draining => "draining",
959            Self::Stopped => "stopped",
960            Self::Failed => "failed",
961            Self::Disabled => "disabled",
962        })
963    }
964}
965
966/// Supervisor classification of a child-process exit.
967#[derive(Debug, Clone, Copy, PartialEq, Eq)]
968pub enum ExitKind {
969    Clean,
970    Crash,
971    DeliberateSeverance,
972}
973
974impl From<ExitKind> for TerminalExitKind {
975    fn from(kind: ExitKind) -> Self {
976        match kind {
977            ExitKind::Clean => Self::Clean,
978            ExitKind::Crash => Self::Crash,
979            ExitKind::DeliberateSeverance => Self::DeliberateSeverance,
980        }
981    }
982}
983
984/// Exact process identity retained when a supervised module registers its
985/// connection. PID reuse makes a PID alone insufficient evidence of ownership.
986#[derive(Debug, Clone, Copy, PartialEq, Eq)]
987pub(crate) struct ProcessIdentity {
988    pub(crate) pid: u32,
989    pub(crate) start_time: u64,
990}
991
992/// Last observed child exit, if any.
993#[derive(Debug, Clone, PartialEq, Eq)]
994pub struct ExitReport {
995    pub kind: ExitKind,
996    pub code: Option<i32>,
997    pub signal: Option<i32>,
998    pub at_ms: u64,
999}
1000
1001/// Point-in-time module status answerable by subc without forwarding to the
1002/// module process.
1003#[derive(Debug, Clone, PartialEq)]
1004pub struct ModuleStatus {
1005    pub module_id: String,
1006    /// A previous daemon's hidden process, not a child owned by this supervisor.
1007    pub waiting_for_previous_pid: Option<u32>,
1008    /// The daemon's Windows per-spawn record: `pipe` after the process it
1009    /// spawned read all nonce bytes, otherwise `env` for the offered copy.
1010    /// List/status reports use this record, not the source the module sends
1011    /// in its registration message's manifest.provenance.launch_nonce_source.
1012    pub launch_nonce_source: Option<String>,
1013    pub state: ModuleState,
1014    pub enabled: bool,
1015    pub process_alive: bool,
1016    pub registration_active: bool,
1017    /// The module's declared wire protocol, carried beside `live` because it is
1018    /// what makes `live` readable: the two fields answer one question together.
1019    /// While a process is alive this is its launch declaration, not a later
1020    /// pending-reload edit. When down it is the configured next launch protocol.
1021    pub protocol: ModuleProtocol,
1022    /// Whether the module is serving, under the strongest definition the daemon
1023    /// can assert for its protocol.
1024    ///
1025    /// A subc module must also be REGISTERED: its process being alive says
1026    /// nothing about whether it can take a request. A `protocol: "none"` module
1027    /// never registers, so that term is dropped and this falls back to "enabled,
1028    /// running, and the process the daemon launched is alive" -- which is all
1029    /// the daemon observes about a process that speaks no subc wire. It stays a
1030    /// `bool` on the wire for compatibility; renderers pair it with `protocol`
1031    /// rather than printing it bare.
1032    pub live: bool,
1033    /// Crash restarts spent INSIDE `restart_window` as of this read. Older
1034    /// restarts have already released their slot, so this count can go down
1035    /// without anybody touching the module.
1036    pub restart_count: u32,
1037    /// Replacement processes spawned over this module's entire supervisor lifetime;
1038    /// unlike `restart_count`, this value is never reset by an operator action
1039    /// and never falls out of a window.
1040    pub lifetime_restarts: u32,
1041    pub spawn_generation: u64,
1042    /// The budget `restart_count` is spent against. Carried alongside the count
1043    /// because the count alone does not say how close the module is to being
1044    /// disabled, and reporting one without the other is what makes an
1045    /// about-to-be-retired module look ordinary.
1046    pub max_restarts: u32,
1047    /// The span `restart_count` is counted over. Carried with the pair above for
1048    /// the same reason they are carried together: "2 of 3" means one thing for a
1049    /// ten-minute window and something else entirely for a lifetime.
1050    pub restart_window: Duration,
1051    /// Effective drain and restart timing policy used by this running module.
1052    /// These values are carried together with the restart budget so status
1053    /// readers can compare configured intent with what the supervisor applied.
1054    pub drain_timeout: Duration,
1055    pub restart_backoff: Duration,
1056    pub restart_max_backoff: Duration,
1057    /// The module's process. On macOS this stays absent while the `ck-subc`
1058    /// launch trampoline is still running in that pid, and appears once the
1059    /// exec acknowledgement confirms the module image has replaced it. Launch
1060    /// time and the supervisor's own hold on the process are unaffected.
1061    pub pid: Option<u32>,
1062    pub spawned_at_ms: Option<u64>,
1063    pub spawned_from: Option<PathBuf>,
1064    pub process_start_time: Option<u64>,
1065    pub last_exit: Option<ExitReport>,
1066    pub health: ModuleHealthStatus,
1067}
1068
1069#[cfg(test)]
1070#[derive(Debug, Clone, Copy, PartialEq)]
1071struct ActorSelectCheckpoint {
1072    generation: u64,
1073    turn: u64,
1074    registered_connection: Option<ConnectionId>,
1075    next_probe_at: Option<Instant>,
1076    wake_after: Option<Duration>,
1077}
1078
1079#[derive(Debug, Clone, PartialEq)]
1080struct SupervisorSnapshot {
1081    #[cfg(windows)]
1082    pipe_consumed: Option<Arc<OnceLock<()>>>,
1083    /// Counts running-child loop turns, not executor polls of a parked wait.
1084    #[cfg(test)]
1085    actor_turns: u64,
1086    /// Running select after exec confirmation and with no unconsumed registry
1087    /// event. Recording a spawn or starting a loop turn is not this barrier.
1088    #[cfg(test)]
1089    actor_select: Option<ActorSelectCheckpoint>,
1090    state: ModuleState,
1091    enabled: bool,
1092    process_alive: bool,
1093    spawned_protocol: Option<ModuleProtocol>,
1094    spawn_failure: Option<String>,
1095    waiting_for_previous_pid: Option<u32>,
1096    /// When each crash restart was spent, oldest first. This IS the crash
1097    /// budget: its in-window length is the count an operator sees and the count
1098    /// the restart decision is made against, so there is no second counter that
1099    /// can disagree with it. Bounded by `max_restarts`, and cleared by the same
1100    /// operator actions that used to zero the old lifetime counter.
1101    crash_restarts: VecDeque<Instant>,
1102    lifetime_restarts: u32,
1103    /// Successful child spawns in this daemon incarnation.
1104    ///
1105    /// `lifetime_restarts` was considered and rejected: it starts at zero
1106    /// (line 640), successful initial/operator spawns in `set_running` do not
1107    /// increment it (lines 5264-5274), and crash/deliberate retry bookkeeping
1108    /// increments before a successful replacement exists (lines 604, 3846,
1109    /// and 3921), so a failed spawn can consume it. This counter moves only
1110    /// when a live PID is accepted below.
1111    spawn_generation: u64,
1112    pid: Option<u32>,
1113    #[cfg(target_os = "macos")]
1114    report_ready: Option<Arc<OnceLock<()>>>,
1115    /// Last reaped child, retained after current process facts are cleared.
1116    reaped_pid: Option<u32>,
1117    /// Whether the command-serving supervision loop has a scheduled respawn.
1118    respawn_pending: bool,
1119    /// A second restart is waiting for the replacement already scheduled.
1120    coalesced_restart_pending: bool,
1121    spawned_at_ms: Option<u64>,
1122    spawned_from: Option<PathBuf>,
1123    spawned_file_identity: Option<SpawnedFileIdentity>,
1124    process_start_time: Option<u64>,
1125    deliberate_severance: Option<ProcessIdentity>,
1126    last_exit: Option<ExitReport>,
1127    /// Diagnostic attached to the next drain's terminal record, if any.
1128    drain_disposition_detail: Option<String>,
1129    health: ModuleHealthStatus,
1130    /// Whether the current process was started as a swap candidate and so
1131    /// lives in the module's alternate cgroup. The next swap's candidate takes
1132    /// the other one, so the two processes of a swap never share a cgroup. A
1133    /// plain spawn always uses the primary cgroup.
1134    in_alternate_slot: bool,
1135    /// Whether the current `Draining` state ends in a replacement process
1136    /// (restart, reload, health restart) rather than a stop. Only meaningful
1137    /// while `state` is `Draining`; every entry into that state rewrites it.
1138    /// It is what lets route.open answer the retryable `module_reloading` to a
1139    /// consumer that reaches a still-registered process mid-restart, instead of
1140    /// the `supervisor_not_live` a stop or disable deserves.
1141    draining_to_replace: bool,
1142    /// Whether a configuration update has been applied since the current
1143    /// process was spawned, so that process runs an older spec than the one
1144    /// the supervisor now holds. A queued restart is only coalesced into a
1145    /// fresher process when this is false: a restart requested to pick up a
1146    /// new configuration must not be satisfied by a process that predates it.
1147    configuration_updated_since_spawn: bool,
1148}
1149
1150impl SupervisorSnapshot {
1151    /// The pid that status, provenance and resource readings may report. While
1152    /// the launch trampoline still runs in the pid, reading its executable or
1153    /// resource use would describe `ck-subc`, not the module, so none is
1154    /// reported until the exec acknowledgement confirms the module image.
1155    fn reported_pid(&self) -> Option<u32> {
1156        #[cfg(target_os = "macos")]
1157        if self
1158            .report_ready
1159            .as_ref()
1160            .is_some_and(|ready| ready.get().is_none())
1161        {
1162            return None;
1163        }
1164        self.pid
1165    }
1166
1167    fn starting() -> Self {
1168        Self::new(ModuleState::Starting, true)
1169    }
1170
1171    fn disabled() -> Self {
1172        Self::new(ModuleState::Disabled, false)
1173    }
1174
1175    fn failed() -> Self {
1176        Self::new(ModuleState::Failed, true)
1177    }
1178
1179    /// Crash restarts still inside `window`, having dropped the ones that are
1180    /// not. Pruning on read is what makes the budget a rate: an instant older
1181    /// than the window stops holding a slot the moment anybody counts.
1182    fn crash_restarts_in_window(&mut self, window: Duration, now: Instant) -> u32 {
1183        while let Some(oldest) = self.crash_restarts.front() {
1184            if now.duration_since(*oldest) > window {
1185                self.crash_restarts.pop_front();
1186            } else {
1187                break;
1188            }
1189        }
1190        u32::try_from(self.crash_restarts.len()).unwrap_or(u32::MAX)
1191    }
1192
1193    /// Spend one unit of the crash budget and record the restart in the ledger.
1194    ///
1195    /// The ring is bounded by the cap because more than `max_restarts` in-window
1196    /// instants can never be reached (the caller refuses the restart first), so
1197    /// anything beyond that is an unbounded queue waiting to happen.
1198    fn record_crash_restart(&mut self, policy: &RestartPolicy, now: Instant) {
1199        self.crash_restarts.push_back(now);
1200        while self.crash_restarts.len() > policy.max_restarts as usize {
1201            self.crash_restarts.pop_front();
1202        }
1203        self.lifetime_restarts += 1;
1204    }
1205
1206    /// Reserve one crash-restart slot and calculate the delay before respawning.
1207    /// The count is captured before recording this restart, so the first retry
1208    /// uses the base delay and each later in-window retry escalates once.
1209    fn next_crash_restart(
1210        &mut self,
1211        policy: &RestartPolicy,
1212        now: Instant,
1213    ) -> Option<CrashRestartSchedule> {
1214        let restart_in_window = self.crash_restarts_in_window(policy.window, now);
1215        if restart_in_window >= policy.max_restarts {
1216            return None;
1217        }
1218        self.record_crash_restart(policy, now);
1219        Some(CrashRestartSchedule {
1220            restart_in_window,
1221            delay: policy.delay_for_restart(restart_in_window),
1222        })
1223    }
1224
1225    /// Give the module its full budget back, as an operator restart, reload, or
1226    /// re-enable does. `lifetime_restarts` deliberately does not move: it is the
1227    /// ledger of what actually happened, and an operator action does not unmake
1228    /// the crashes.
1229    fn clear_crash_restarts(&mut self) {
1230        self.crash_restarts.clear();
1231    }
1232
1233    fn new(state: ModuleState, enabled: bool) -> Self {
1234        Self {
1235            #[cfg(test)]
1236            actor_turns: 0,
1237            #[cfg(test)]
1238            actor_select: None,
1239            state,
1240            enabled,
1241            process_alive: false,
1242            #[cfg(windows)]
1243            pipe_consumed: None,
1244            spawned_protocol: None,
1245            spawn_failure: None,
1246            waiting_for_previous_pid: None,
1247            crash_restarts: VecDeque::new(),
1248            lifetime_restarts: 0,
1249            spawn_generation: 0,
1250            pid: None,
1251            #[cfg(target_os = "macos")]
1252            report_ready: None,
1253            reaped_pid: None,
1254            respawn_pending: false,
1255            coalesced_restart_pending: false,
1256            spawned_at_ms: None,
1257            spawned_from: None,
1258            spawned_file_identity: None,
1259            process_start_time: None,
1260            deliberate_severance: None,
1261            last_exit: None,
1262            drain_disposition_detail: None,
1263            health: ModuleHealthStatus::default(),
1264            in_alternate_slot: false,
1265            draining_to_replace: false,
1266            configuration_updated_since_spawn: false,
1267        }
1268    }
1269}
1270
1271type SharedSnapshot = Arc<Mutex<SupervisorSnapshot>>;
1272
1273type SpawnSubscriberKey = (ConnectionId, u64);
1274
1275#[derive(Debug)]
1276struct SpawnSubscriber {
1277    version: u8,
1278    frames: mpsc::Sender<Frame>,
1279    /// Tells this subscriber's forwarder that it was dropped for lagging, and
1280    /// from which event. The full frame channel cannot carry that news, so it
1281    /// travels beside it; see `SpawnEventFeed::subscribe`.
1282    lagged: Option<oneshot::Sender<SpawnCursor>>,
1283}
1284
1285#[derive(Debug)]
1286struct SpawnEventState {
1287    daemon_incarnation: String,
1288    seq: u64,
1289    capacity: usize,
1290    live: HashMap<String, LiveSpawn>,
1291    generations: HashMap<String, u64>,
1292    events: VecDeque<SpawnEvent>,
1293    subscribers: HashMap<SpawnSubscriberKey, SpawnSubscriber>,
1294}
1295
1296impl Default for SpawnEventState {
1297    fn default() -> Self {
1298        Self {
1299            daemon_incarnation: "unconfigured".to_string(),
1300            seq: 0,
1301            capacity: SPAWN_EVENT_RING_CAPACITY,
1302            live: HashMap::new(),
1303            generations: HashMap::new(),
1304            events: VecDeque::new(),
1305            subscribers: HashMap::new(),
1306        }
1307    }
1308}
1309
1310#[derive(Debug, Clone, Default)]
1311struct SpawnEventFeed(
1312    Arc<Mutex<SpawnEventState>>,
1313    #[cfg(test)] Arc<Mutex<Option<std::net::TcpStream>>>,
1314);
1315
1316#[derive(Debug, Clone, PartialEq, Eq)]
1317pub(crate) enum SpawnSubscribeRefusal {
1318    ForeignIncarnation { current: String },
1319    TooOld { oldest: SpawnCursor },
1320    Frame(String),
1321}
1322
1323impl SpawnEventFeed {
1324    fn configure_incarnation(&self, daemon_incarnation: String) {
1325        let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1326        state.daemon_incarnation = daemon_incarnation;
1327        state.seq = 0;
1328        state.live.clear();
1329        state.generations.clear();
1330        state.events.clear();
1331        state.subscribers.clear();
1332    }
1333
1334    fn cursor(state: &SpawnEventState) -> SpawnCursor {
1335        SpawnCursor {
1336            daemon_incarnation: state.daemon_incarnation.clone(),
1337            seq: state.seq,
1338        }
1339    }
1340
1341    fn snapshot(&self) -> SpawnSnapshot {
1342        let state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1343        let mut live = state.live.values().cloned().collect::<Vec<_>>();
1344        live.sort_by(|left, right| left.module_id.cmp(&right.module_id));
1345        SpawnSnapshot {
1346            cursor: Self::cursor(&state),
1347            ring_bound: state.capacity as u64,
1348            live,
1349        }
1350    }
1351
1352    fn emit_spawned(&self, module_id: &str, pid: u32, spawned_at_ms: u64) -> u64 {
1353        let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1354        let generation = state
1355            .generations
1356            .get(module_id)
1357            .copied()
1358            .unwrap_or(0)
1359            .checked_add(1)
1360            .expect("spawn generation exhausted");
1361        state.generations.insert(module_id.to_string(), generation);
1362        let live = LiveSpawn {
1363            module_id: module_id.to_string(),
1364            spawn_generation: generation,
1365            pid,
1366            spawned_at_ms,
1367        };
1368        state.live.insert(module_id.to_string(), live);
1369        Self::emit_locked(
1370            &mut state,
1371            SpawnEventKind::Spawned,
1372            module_id.to_string(),
1373            generation,
1374            pid,
1375            None,
1376            None,
1377        );
1378        generation
1379    }
1380
1381    fn emit_exited(&self, module_id: &str, exit_code: Option<i32>, exit_signal: Option<i32>) {
1382        let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1383        let Some(live) = state.live.remove(module_id) else {
1384            warn!(
1385                module_id,
1386                "terminal record had no live spawn event identity"
1387            );
1388            return;
1389        };
1390        Self::emit_locked(
1391            &mut state,
1392            SpawnEventKind::Exited,
1393            module_id.to_string(),
1394            live.spawn_generation,
1395            live.pid,
1396            exit_code,
1397            exit_signal,
1398        );
1399    }
1400
1401    /// Report the exit of a process that a swap has already replaced.
1402    ///
1403    /// `emit_exited` removes the module's live entry, which after a swap's
1404    /// cutover describes the promoted candidate, not the old process now
1405    /// exiting. This emits the old generation's exit and leaves the live entry
1406    /// alone unless it still names that generation.
1407    fn emit_superseded_exited(
1408        &self,
1409        module_id: &str,
1410        spawn_generation: u64,
1411        pid: u32,
1412        exit_code: Option<i32>,
1413        exit_signal: Option<i32>,
1414    ) {
1415        let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1416        if state
1417            .live
1418            .get(module_id)
1419            .is_some_and(|live| live.spawn_generation == spawn_generation)
1420        {
1421            state.live.remove(module_id);
1422        }
1423        Self::emit_locked(
1424            &mut state,
1425            SpawnEventKind::Exited,
1426            module_id.to_string(),
1427            spawn_generation,
1428            pid,
1429            exit_code,
1430            exit_signal,
1431        );
1432    }
1433
1434    #[allow(clippy::too_many_arguments)]
1435    fn emit_locked(
1436        state: &mut SpawnEventState,
1437        kind: SpawnEventKind,
1438        module_id: String,
1439        spawn_generation: u64,
1440        pid: u32,
1441        exit_code: Option<i32>,
1442        exit_signal: Option<i32>,
1443    ) {
1444        state.seq = state
1445            .seq
1446            .checked_add(1)
1447            .expect("spawn event sequence exhausted");
1448        let event = SpawnEvent {
1449            cursor: Self::cursor(state),
1450            kind,
1451            module_id,
1452            spawn_generation,
1453            pid,
1454            exit_code,
1455            exit_signal,
1456        };
1457        state.events.push_back(event.clone());
1458        while state.events.len() > state.capacity {
1459            state.events.pop_front();
1460        }
1461        let body = match serde_json::to_vec(&event) {
1462            Ok(body) => body,
1463            Err(error) => {
1464                error!(%error, "failed to serialize supervisor spawn event");
1465                return;
1466            }
1467        };
1468        state.subscribers.retain(|(connection_id, corr), subscriber| {
1469            let frame = Frame::build_with_version(
1470                subscriber.version,
1471                FrameType::StreamData,
1472                control_flags(),
1473                0,
1474                0,
1475                *corr,
1476                body.clone(),
1477            );
1478            match frame {
1479                Ok(frame) => {
1480                    if subscriber.frames.try_send(frame).is_ok() {
1481                        true
1482                    } else {
1483                        warn!(connection_id = connection_id.get(), corr, "dropping lagged supervisor spawn subscriber");
1484                        if let Some(lagged) = subscriber.lagged.take() {
1485                            let _ = lagged.send(event.cursor.clone());
1486                        }
1487                        false
1488                    }
1489                }
1490                Err(error) => {
1491                    warn!(connection_id = connection_id.get(), corr, %error, "dropping supervisor spawn subscriber after frame build failure");
1492                    false
1493                }
1494            }
1495        });
1496    }
1497
1498    fn subscribe(
1499        &self,
1500        connection_id: ConnectionId,
1501        corr: u64,
1502        version: u8,
1503        since: Option<SpawnCursor>,
1504        sink: FrameSink,
1505    ) -> Result<(), SpawnSubscribeRefusal> {
1506        let (frames, mut receiver) = mpsc::channel(SPAWN_SUBSCRIBER_BUFFER);
1507        let (lagged, mut lagged_rx) = oneshot::channel::<SpawnCursor>();
1508        {
1509            let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1510            let replay = if let Some(since) = since {
1511                if since.daemon_incarnation != state.daemon_incarnation {
1512                    return Err(SpawnSubscribeRefusal::ForeignIncarnation {
1513                        current: state.daemon_incarnation.clone(),
1514                    });
1515                }
1516                if let Some(oldest) = state.events.front().map(|event| event.cursor.clone()) {
1517                    if since.seq < oldest.seq.saturating_sub(1) {
1518                        return Err(SpawnSubscribeRefusal::TooOld { oldest });
1519                    }
1520                }
1521                state
1522                    .events
1523                    .iter()
1524                    .filter(|event| event.cursor.seq > since.seq)
1525                    .cloned()
1526                    .collect::<Vec<_>>()
1527            } else {
1528                Vec::new()
1529            };
1530            for event in replay {
1531                let body = serde_json::to_vec(&event)
1532                    .map_err(|error| SpawnSubscribeRefusal::Frame(error.to_string()))?;
1533                let frame = Frame::build_with_version(
1534                    version,
1535                    FrameType::StreamData,
1536                    control_flags(),
1537                    0,
1538                    0,
1539                    corr,
1540                    body,
1541                )
1542                .map_err(|error| SpawnSubscribeRefusal::Frame(error.to_string()))?;
1543                frames
1544                    .try_send(frame)
1545                    .map_err(|error| SpawnSubscribeRefusal::Frame(error.to_string()))?;
1546            }
1547            state.subscribers.insert(
1548                (connection_id, corr),
1549                SpawnSubscriber {
1550                    version,
1551                    frames,
1552                    lagged: Some(lagged),
1553                },
1554            );
1555        }
1556        // The lagged terminal is sent here, by the forwarder, rather than by
1557        // the emitter: at the moment of the drop the subscriber's own channel
1558        // is full, and writing to the connection sink directly from the emitter
1559        // would put the Error AHEAD of the events still queued in that channel
1560        // (and the emitter holds the feed lock, so it cannot await the sink).
1561        // Dropping the subscriber drops the only sender, so `recv` drains every
1562        // queued event and then returns `None`; only then is the Error sent, so
1563        // the client sees each event it can keep, then the reason it was cut.
1564        // Cancel and connection removal drop the oneshot unsent, so they end
1565        // the stream with no Error.
1566        tokio::spawn(async move {
1567            while let Some(frame) = receiver.recv().await {
1568                if sink.send(frame).await.is_err() {
1569                    return;
1570                }
1571            }
1572            let Ok(first_undelivered) = lagged_rx.try_recv() else {
1573                return;
1574            };
1575            match spawn_subscriber_lagged_frame(version, corr, first_undelivered) {
1576                Ok(frame) => {
1577                    let _ = sink.send(frame).await;
1578                }
1579                Err(error) => {
1580                    error!(%error, corr, "failed to build lagged spawn subscriber terminal frame");
1581                }
1582            }
1583        });
1584        Ok(())
1585    }
1586
1587    fn cancel(&self, connection_id: ConnectionId, corr: u64) -> bool {
1588        let Some(subscriber) = self
1589            .0
1590            .lock()
1591            .unwrap_or_else(|p| p.into_inner())
1592            .subscribers
1593            .remove(&(connection_id, corr))
1594        else {
1595            return false;
1596        };
1597        if let Ok(frame) = Frame::build_with_version(
1598            subscriber.version,
1599            FrameType::StreamEnd,
1600            control_flags(),
1601            0,
1602            0,
1603            corr,
1604            Vec::new(),
1605        ) {
1606            tokio::spawn(async move {
1607                let _ = subscriber.frames.send(frame).await;
1608            });
1609        }
1610        true
1611    }
1612
1613    fn remove_connection(&self, connection_id: ConnectionId) {
1614        self.0
1615            .lock()
1616            .unwrap_or_else(|p| p.into_inner())
1617            .subscribers
1618            .retain(|(subscriber_connection, _), _| *subscriber_connection != connection_id);
1619    }
1620
1621    #[cfg(any(test, feature = "test-support"))]
1622    fn set_capacity(&self, capacity: usize) {
1623        self.0.lock().unwrap_or_else(|p| p.into_inner()).capacity = capacity;
1624    }
1625
1626    #[cfg(any(test, feature = "test-support"))]
1627    fn subscriber_count(&self) -> usize {
1628        self.0
1629            .lock()
1630            .unwrap_or_else(|p| p.into_inner())
1631            .subscribers
1632            .len()
1633    }
1634}
1635
1636/// Narrow process-liveness signal published by supervisors and consumed by passive liveness polls.
1637/// The terminal Error a lagged spawn subscriber receives after its queued events.
1638fn spawn_subscriber_lagged_frame(
1639    version: u8,
1640    corr: u64,
1641    first_undelivered: SpawnCursor,
1642) -> Result<Frame, String> {
1643    let body = serde_json::to_vec(&subc_protocol::ErrorBody {
1644        code: SPAWN_SUBSCRIBER_LAGGED_CODE.to_string(),
1645        message: "spawn subscriber fell behind and was dropped; resubscribe from the last cursor received"
1646            .to_string(),
1647        detail: Some(serde_json::json!({
1648            "first_undelivered_cursor": first_undelivered
1649        })),
1650    })
1651    .map_err(|error| error.to_string())?;
1652    Frame::build_with_version(version, FrameType::Error, control_flags(), 0, 0, corr, body)
1653        .map_err(|error| error.to_string())
1654}
1655
1656pub trait ModuleProcessLiveness: Send + Sync {
1657    fn process_live(&self, module_id: &str) -> Option<bool>;
1658
1659    /// Whether the supervisor is replacing this module's process right now: an
1660    /// operator restart or reload, a health restart, or a crash respawn whose
1661    /// backoff is running. A module in that state is not live, but a consumer
1662    /// refused now should retry shortly rather than treat the target as gone.
1663    /// Stopped, failed, and disabled modules are not replacing.
1664    fn process_replacing(&self, _module_id: &str) -> bool {
1665        false
1666    }
1667}
1668
1669/// Shared process-liveness registry keyed by supervised `module_id`.
1670#[derive(Debug, Clone, Default)]
1671pub struct SupervisorProcessLiveness {
1672    snapshots: Arc<Mutex<HashMap<String, SharedSnapshot>>>,
1673}
1674
1675impl SupervisorProcessLiveness {
1676    pub fn new() -> Self {
1677        Self::default()
1678    }
1679
1680    fn track(&self, module_id: String, snapshot: SharedSnapshot) {
1681        let mut snapshots = self
1682            .snapshots
1683            .lock()
1684            .unwrap_or_else(|poisoned| poisoned.into_inner());
1685        snapshots.insert(module_id, snapshot);
1686    }
1687
1688    fn untrack_if_current(&self, module_id: &str, snapshot: &SharedSnapshot) {
1689        let mut snapshots = self
1690            .snapshots
1691            .lock()
1692            .unwrap_or_else(|poisoned| poisoned.into_inner());
1693        let is_current = snapshots
1694            .get(module_id)
1695            .map(|tracked| Arc::ptr_eq(tracked, snapshot))
1696            .unwrap_or(false);
1697        if is_current {
1698            snapshots.remove(module_id);
1699        }
1700    }
1701}
1702
1703impl ModuleProcessLiveness for SupervisorProcessLiveness {
1704    fn process_live(&self, module_id: &str) -> Option<bool> {
1705        let snapshot = {
1706            let snapshots = self
1707                .snapshots
1708                .lock()
1709                .unwrap_or_else(|poisoned| poisoned.into_inner());
1710            snapshots.get(module_id).cloned()
1711        }?;
1712        let snapshot = snapshot
1713            .lock()
1714            .unwrap_or_else(|poisoned| poisoned.into_inner());
1715        Some(snapshot.state == ModuleState::Running && snapshot.process_alive)
1716    }
1717
1718    fn process_replacing(&self, module_id: &str) -> bool {
1719        let Some(snapshot) = self
1720            .snapshots
1721            .lock()
1722            .unwrap_or_else(|poisoned| poisoned.into_inner())
1723            .get(module_id)
1724            .cloned()
1725        else {
1726            return false;
1727        };
1728        let snapshot = snapshot
1729            .lock()
1730            .unwrap_or_else(|poisoned| poisoned.into_inner());
1731        snapshot.enabled
1732            && match snapshot.state {
1733                ModuleState::Restarting => true,
1734                ModuleState::Draining => snapshot.draining_to_replace,
1735                ModuleState::Starting
1736                | ModuleState::Running
1737                | ModuleState::Unresponsive
1738                | ModuleState::Stopped
1739                | ModuleState::Failed
1740                | ModuleState::Disabled => false,
1741            }
1742    }
1743}
1744
1745#[cfg(test)]
1746#[derive(Debug, Default)]
1747struct ReloadExitRecordGate {
1748    reached: tokio::sync::Notify,
1749    resume: tokio::sync::Notify,
1750}
1751
1752#[derive(Debug, Clone, Copy)]
1753enum RespawnKind {
1754    Spawn,
1755    Reload,
1756}
1757
1758#[derive(Debug, Clone, Copy)]
1759struct PendingRespawn {
1760    deadline: Instant,
1761    kind: RespawnKind,
1762}
1763
1764type ReloadReply = oneshot::Sender<Result<(), SuperviseError>>;
1765
1766#[derive(Debug, Clone)]
1767struct SupervisorRuntimeConfig {
1768    /// Restart operations hand their backoff to the loop, which keeps serving commands.
1769    scheduled_respawn: Arc<Mutex<Option<PendingRespawn>>>,
1770    /// A reload acknowledges completion only after its replacement registers.
1771    deferred_reload_reply: Arc<Mutex<Option<ReloadReply>>>,
1772    restart_policy: RestartPolicy,
1773    /// This module's RESOLVED drain budget: per-module config when present,
1774    /// else `default_drain_timeout`.
1775    drain_timeout: Duration,
1776    /// Shared with the status handle so the attested value changes atomically
1777    /// when a rescan updates the running drain policy.
1778    effective_drain_timeout: Arc<Mutex<Duration>>,
1779    /// The supervisor-wide fallback, kept so a configuration update that
1780    /// REMOVES the per-module override can re-resolve to it.
1781    default_drain_timeout: Duration,
1782    health: HealthConfig,
1783    connection_file_path: Option<PathBuf>,
1784    capture_logs_dir: Option<PathBuf>,
1785    forwarding: Option<Arc<ForwardingTable>>,
1786    /// The shared handle, so every spawn path (initial, restart, reload) records the
1787    /// reserved-module launch nonce the HELLO verifier checks against.
1788    supervisor_handle: Option<SupervisorHandle>,
1789    /// This module's stderr tail, shared with the [`SupervisedModule`] that answers
1790    /// status queries.
1791    ///
1792    /// One ring per module, held across every respawn. The lines explaining an exit
1793    /// are written BEFORE that exit, so a ring recreated per process would be empty
1794    /// exactly when it is asked for.
1795    stderr_ring: Arc<Mutex<StderrRing>>,
1796    terminal_ring: Arc<Mutex<TerminalRing>>,
1797    spawn_events: SpawnEventFeed,
1798    child_roster: ChildRoster,
1799    #[cfg(target_os = "linux")]
1800    cgroup_placement: Option<subc_cgroup::Placement>,
1801    #[cfg(test)]
1802    test_seed_stale_facts_before_enable_spawn: bool,
1803    #[cfg(test)]
1804    test_reload_exit_record_gate: Option<Arc<ReloadExitRecordGate>>,
1805}
1806
1807#[derive(Debug, Clone, PartialEq, Eq)]
1808struct SupervisedConfiguration {
1809    spec: ModuleSpec,
1810    health: HealthConfig,
1811}
1812
1813/// Shared daemon lookup table for supervised module handles.
1814///
1815/// Shared by clone between the [`Supervisor`] (which spawns processes) and the
1816/// channel-0 control handler (which verifies HELLOs and consumer route opens), so
1817/// launch nonces recorded at spawn are checked by the same daemon instance.
1818#[derive(Debug, Clone, Default)]
1819pub struct SupervisorHandle {
1820    modules: Arc<Mutex<HashMap<String, SupervisedModule>>>,
1821    /// Module ids the supervisor has taken on. An id is added BEFORE the
1822    /// module's first process is spawned and removed only when the module
1823    /// leaves the roster (see [`Self::retire`]), so it is always a superset of
1824    /// the keys of `modules`.
1825    ///
1826    /// `modules` cannot answer "is this module configured?" on its own: a
1827    /// [`SupervisedModule`] only exists once its process has been spawned, and
1828    /// a fast child can connect, register, sync its scopes and ask about them
1829    /// before the supervisor has inserted it. Answering "not configured" in that
1830    /// gap makes scope admission refuse with the terminal "will never sync"
1831    /// instead of the retryable "has not synced yet".
1832    configured_ids: Arc<Mutex<HashSet<String>>>,
1833    spawn_events: SpawnEventFeed,
1834    /// The current expected launch nonce for each reserved module_id. Set when the
1835    /// supervisor spawns the reserved module; checked when a HELLO claims that id. A
1836    /// non-reserved module never has an entry here and is never nonce-checked.
1837    /// Reserved module ids and the nonce that authorizes their next HELLO.
1838    /// `None` means RESERVED WITH NO LEGITIMATE HOLDER — a reserved module that
1839    /// has never been spawned (e.g. configured `enabled: false`) — and refuses
1840    /// every HELLO. Before this was expressible, a reserved-but-never-spawned id
1841    /// had NO entry and admitted anyone: the reservation protected the nonce
1842    /// holder, not the NAME (found live by CKCRED's canary probe registering
1843    /// against a reserved scratch id).
1844    reserved_nonces: Arc<Mutex<HashMap<String, Option<String>>>>,
1845    /// Module ids removed by an executed rescan and the unix-millisecond removal time.
1846    ///
1847    /// This is deliberately in-memory only: subc is state-free across daemon
1848    /// restarts, and the tombstone only explains the hours-after-removal window
1849    /// while this executing daemon is still alive. Do not persist it in a store.
1850    removal_tombstones: Arc<Mutex<HashMap<String, u64>>>,
1851    /// The current launch nonce for every supervised spawn. This is separate from
1852    /// reserved_nonces because consumer route.open attestation applies to all spawned
1853    /// modules, while HELLO id-squatting protection remains opt-in via `reserved`.
1854    spawn_nonces: Arc<Mutex<HashMap<String, String>>>,
1855    /// Reserved namespace prefixes mapped to the supervised owner module whose
1856    /// current spawn nonce authorizes HELLO claims below the prefix.
1857    ///
1858    /// Per §2.6 this is not a same-user security barrier: a same-user process can
1859    /// read the key file and launch nonce env. Like exact reserved ids, it prevents
1860    /// accidental collisions and lower-trust processes from squatting protected
1861    /// namespaces.
1862    reserved_prefix_owners: Arc<Mutex<HashMap<String, String>>>,
1863    /// Blue/green swaps in progress, by module id. An entry exists from just
1864    /// before the candidate process is spawned until the swap has failed, or
1865    /// has cut over and the old process is gone. While it exists, HELLO for the
1866    /// id is gated on the swap token (see [`Self::swap_hello_admission`]) and
1867    /// consumer attestation accepts both processes' nonces.
1868    swaps: Arc<Mutex<HashMap<String, OpenSwap>>>,
1869    /// Told when a swap promotes its candidate; see [`SwapPromotionObserver`].
1870    promotion_observer: PromotionObserverSlot,
1871    /// Serializes module-set reconciliation with operator lifecycle commands. Without
1872    /// this daemon-wide ordering, a rescan could retire or update a module while a
1873    /// concurrent reload still held its old handle and launch specification.
1874    operation_lock: Arc<AsyncMutex<()>>,
1875}
1876
1877/// Told when a swap has promoted its candidate to be the module's active
1878/// registration.
1879///
1880/// An ordinary HELLO runs the control plane's registration side effects (the
1881/// capability cache, the deny census, the requirement recompute) as it
1882/// registers. A swap candidate's HELLO does not, because it is not routable;
1883/// promotion is when those must run instead, and promotion happens in the
1884/// supervisor, which has no other way into the control handler.
1885pub(crate) trait SwapPromotionObserver: Send + Sync {
1886    fn swap_promoted(&self, registration: &crate::registry::ModuleRegistration);
1887}
1888
1889/// The installed [`SwapPromotionObserver`], held weakly: the observer (the
1890/// control handler) owns this handle, so a strong reference back would be a
1891/// cycle that keeps both alive.
1892#[derive(Clone, Default)]
1893struct PromotionObserverSlot(Arc<Mutex<Option<std::sync::Weak<dyn SwapPromotionObserver>>>>);
1894
1895impl fmt::Debug for PromotionObserverSlot {
1896    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1897        f.write_str("PromotionObserverSlot")
1898    }
1899}
1900
1901/// The nonces of one open swap.
1902#[derive(Debug, Clone)]
1903struct OpenSwap {
1904    /// The launch nonce minted for the candidate process. It is the swap
1905    /// token: the only thing that admits a HELLO into the candidate slot.
1906    candidate_nonce: String,
1907    /// The incumbent's launch nonce, captured when the swap opened. It is kept
1908    /// here because cutover moves the module's recorded spawn nonce to the
1909    /// candidate while the incumbent is still draining and its consumers are
1910    /// still attesting with this one.
1911    incumbent_nonce: Option<String>,
1912    /// Set once a HELLO has been admitted with the swap token, so the token
1913    /// admits one registration and cannot be replayed after cutover empties
1914    /// the candidate slot.
1915    candidate_admitted: bool,
1916}
1917
1918/// What the swap gate says about a HELLO. See
1919/// [`SupervisorHandle::swap_hello_admission`].
1920#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1921pub(crate) enum SwapHelloAdmission {
1922    /// No swap is open for the id (or the HELLO carries the incumbent's own
1923    /// nonce); the ordinary gates decide.
1924    NotSwapping,
1925    /// The HELLO carries the swap token: register it into the candidate slot.
1926    Candidate,
1927    /// A swap is open and the HELLO carries a nonce the supervisor did not
1928    /// mint for this id, no nonce, or a token already used.
1929    Refused,
1930}
1931
1932#[derive(Debug, Clone, PartialEq, Eq)]
1933pub(crate) enum ReservedHelloRejection {
1934    Exact {
1935        module_id: String,
1936    },
1937    Prefix {
1938        prefix: String,
1939        owner_module_id: String,
1940    },
1941}
1942
1943impl SupervisorHandle {
1944    pub fn new() -> Self {
1945        Self::default()
1946    }
1947
1948    pub(crate) fn spawn_snapshot(&self) -> SpawnSnapshot {
1949        self.spawn_events.snapshot()
1950    }
1951
1952    pub(crate) fn subscribe_spawns(
1953        &self,
1954        connection_id: ConnectionId,
1955        corr: u64,
1956        version: u8,
1957        since: Option<SpawnCursor>,
1958        sink: FrameSink,
1959    ) -> Result<(), SpawnSubscribeRefusal> {
1960        self.spawn_events
1961            .subscribe(connection_id, corr, version, since, sink)
1962    }
1963
1964    pub(crate) fn cancel_spawn_subscription(&self, connection_id: ConnectionId, corr: u64) -> bool {
1965        self.spawn_events.cancel(connection_id, corr)
1966    }
1967
1968    pub(crate) fn remove_spawn_subscribers(&self, connection_id: ConnectionId) {
1969        self.spawn_events.remove_connection(connection_id);
1970    }
1971
1972    #[cfg(any(test, feature = "test-support"))]
1973    pub fn set_spawn_event_capacity_for_test(&self, capacity: usize) {
1974        assert!(capacity > 0, "spawn event capacity must be non-zero");
1975        self.spawn_events.set_capacity(capacity);
1976    }
1977
1978    #[cfg(any(test, feature = "test-support"))]
1979    pub fn spawn_subscriber_count_for_test(&self) -> usize {
1980        self.spawn_events.subscriber_count()
1981    }
1982
1983    /// Record the launch nonce from a supervised spawn, replacing any prior nonce so
1984    /// a respawn invalidates stale consumer identities.
1985    pub fn set_spawn_nonce(&self, module_id: &str, nonce: String) {
1986        self.spawn_nonces
1987            .lock()
1988            .unwrap_or_else(|poisoned| poisoned.into_inner())
1989            .insert(module_id.to_string(), nonce);
1990    }
1991
1992    /// Record the launch nonce expected from the next HELLO for a reserved module,
1993    /// replacing any prior nonce (a respawn invalidates the previous one).
1994    pub fn set_reserved_nonce(&self, module_id: &str, nonce: String) {
1995        self.reserved_nonces
1996            .lock()
1997            .unwrap_or_else(|poisoned| poisoned.into_inner())
1998            .insert(module_id.to_string(), Some(nonce));
1999    }
2000
2001    /// Record namespace prefixes owned by a supervised module.
2002    pub fn set_reserved_prefixes(&self, owner_module_id: &str, prefixes: &[String]) {
2003        let mut owners = self
2004            .reserved_prefix_owners
2005            .lock()
2006            .unwrap_or_else(|poisoned| poisoned.into_inner());
2007        owners.retain(|_, owner| owner != owner_module_id);
2008        for prefix in prefixes {
2009            owners.insert(prefix.clone(), owner_module_id.to_string());
2010        }
2011    }
2012
2013    /// The launch nonce most recently minted for a module's spawn, if any.
2014    #[cfg(test)]
2015    pub(crate) fn spawn_nonce(&self, module_id: &str) -> Option<String> {
2016        self.spawn_nonces
2017            .lock()
2018            .unwrap_or_else(|poisoned| poisoned.into_inner())
2019            .get(module_id)
2020            .cloned()
2021    }
2022
2023    fn apply_identity_configuration(&self, spec: &ModuleSpec) {
2024        self.set_reserved_prefixes(&spec.module_id, &spec.reserved_prefixes);
2025        let spawn_nonce = self
2026            .spawn_nonces
2027            .lock()
2028            .unwrap_or_else(|poisoned| poisoned.into_inner())
2029            .get(&spec.module_id)
2030            .cloned();
2031        let mut reserved_nonces = self
2032            .reserved_nonces
2033            .lock()
2034            .unwrap_or_else(|poisoned| poisoned.into_inner());
2035        if spec.reserved {
2036            // `None` (no spawn nonce minted) is INSERTED, not skipped: a
2037            // reserved name whose module has never spawned has no legitimate
2038            // holder, and the entry's absence is what used to leave the name
2039            // open to the first claimant.
2040            reserved_nonces.insert(spec.module_id.clone(), spawn_nonce);
2041        }
2042        drop(reserved_nonces);
2043        // A later unreserved declaration must not silently unreserve an id that
2044        // was retained after its reserved configuration was removed. The explicit
2045        // release ceremony is the only operation that retires that gate.
2046        self.removal_tombstones
2047            .lock()
2048            .unwrap_or_else(|poisoned| poisoned.into_inner())
2049            .remove(&spec.module_id);
2050    }
2051
2052    /// Whether a HELLO claiming `module_id` is authorized. An exact reserved id is
2053    /// authorized only by its expected nonce; otherwise a matching reserved prefix
2054    /// is authorized by the owner module's current spawn nonce. Non-reserved ids
2055    /// with no matching prefix are always authorized.
2056    pub fn reserved_hello_authorized(&self, module_id: &str, presented: Option<&str>) -> bool {
2057        self.reserved_hello_rejection(module_id, presented)
2058            .is_none()
2059    }
2060
2061    pub(crate) fn reserved_hello_rejection(
2062        &self,
2063        module_id: &str,
2064        presented: Option<&str>,
2065    ) -> Option<ReservedHelloRejection> {
2066        let nonces = self
2067            .reserved_nonces
2068            .lock()
2069            .unwrap_or_else(|poisoned| poisoned.into_inner());
2070        if let Some(expected) = nonces.get(module_id) {
2071            // `None` = reserved with no legitimate holder: refuse every
2072            // presentation, because no process can hold a nonce that was never
2073            // minted. Only a real minted nonce admits, in constant time.
2074            let authorized = match expected {
2075                Some(expected) => {
2076                    presented.is_some_and(|p| constant_time_eq(expected.as_bytes(), p.as_bytes()))
2077                }
2078                None => false,
2079            };
2080            if authorized {
2081                return None;
2082            }
2083            return Some(ReservedHelloRejection::Exact {
2084                module_id: module_id.to_string(),
2085            });
2086        }
2087        drop(nonces);
2088
2089        let matched_prefix = self
2090            .reserved_prefix_owners
2091            .lock()
2092            .unwrap_or_else(|poisoned| poisoned.into_inner())
2093            .iter()
2094            .filter(|(prefix, _)| module_id.starts_with(prefix.as_str()))
2095            .max_by_key(|(prefix, _)| prefix.len())
2096            .map(|(prefix, owner)| (prefix.clone(), owner.clone()));
2097        let (prefix, owner_module_id) = matched_prefix?;
2098
2099        let authorized = presented.is_some_and(|presented| {
2100            self.spawn_nonces
2101                .lock()
2102                .unwrap_or_else(|poisoned| poisoned.into_inner())
2103                .get(&owner_module_id)
2104                .is_some_and(|expected| constant_time_eq(expected.as_bytes(), presented.as_bytes()))
2105                // While the owner is being swapped, children started by
2106                // either of its two processes hold that process's nonce.
2107                || self.swap_nonce_matches(&owner_module_id, presented)
2108        });
2109        if authorized {
2110            None
2111        } else {
2112            Some(ReservedHelloRejection::Prefix {
2113                prefix,
2114                owner_module_id,
2115            })
2116        }
2117    }
2118
2119    /// Whether a consumer connection proved it came from a daemon-spawned module.
2120    ///
2121    /// Absence of an expected spawn nonce is a hard failure: consumer_identity is
2122    /// accepted only for module ids the supervisor has spawned.
2123    pub fn spawned_consumer_authorized(&self, module_id: &str, presented: &str) -> bool {
2124        if presented.is_empty() {
2125            return false;
2126        }
2127        let nonces = self
2128            .spawn_nonces
2129            .lock()
2130            .unwrap_or_else(|poisoned| poisoned.into_inner());
2131        let current = nonces
2132            .get(module_id)
2133            .is_some_and(|expected| constant_time_eq(expected.as_bytes(), presented.as_bytes()));
2134        drop(nonces);
2135        // During a swap two processes of the module are alive, and a consumer
2136        // started by either one presents that process's nonce. Accepting only
2137        // the recorded one would fail the incumbent's consumers for the whole
2138        // overlap once cutover moves the record to the candidate.
2139        current || self.swap_nonce_matches(module_id, presented)
2140    }
2141
2142    /// Whether `presented` is either nonce of an open swap for `module_id`.
2143    fn swap_nonce_matches(&self, module_id: &str, presented: &str) -> bool {
2144        let swaps = self
2145            .swaps
2146            .lock()
2147            .unwrap_or_else(|poisoned| poisoned.into_inner());
2148        swaps.get(module_id).is_some_and(|swap| {
2149            constant_time_eq(swap.candidate_nonce.as_bytes(), presented.as_bytes())
2150                || swap.incumbent_nonce.as_deref().is_some_and(|incumbent| {
2151                    constant_time_eq(incumbent.as_bytes(), presented.as_bytes())
2152                })
2153        })
2154    }
2155
2156    /// Open a swap for `module_id` with the candidate's freshly minted nonce.
2157    /// Called before the candidate process exists.
2158    pub(crate) fn open_swap(&self, module_id: &str, candidate_nonce: String) {
2159        let incumbent_nonce = self
2160            .spawn_nonces
2161            .lock()
2162            .unwrap_or_else(|poisoned| poisoned.into_inner())
2163            .get(module_id)
2164            .cloned();
2165        self.swaps
2166            .lock()
2167            .unwrap_or_else(|poisoned| poisoned.into_inner())
2168            .insert(
2169                module_id.to_string(),
2170                OpenSwap {
2171                    candidate_nonce,
2172                    incumbent_nonce,
2173                    candidate_admitted: false,
2174                },
2175            );
2176    }
2177
2178    /// Close the swap for `module_id`, releasing whichever nonce is no longer
2179    /// the module's recorded one.
2180    pub(crate) fn close_swap(&self, module_id: &str) {
2181        self.swaps
2182            .lock()
2183            .unwrap_or_else(|poisoned| poisoned.into_inner())
2184            .remove(module_id);
2185    }
2186
2187    /// Install the observer told about swap promotions, replacing any earlier
2188    /// one.
2189    pub(crate) fn set_swap_promotion_observer(
2190        &self,
2191        observer: std::sync::Weak<dyn SwapPromotionObserver>,
2192    ) {
2193        *self
2194            .promotion_observer
2195            .0
2196            .lock()
2197            .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(observer);
2198    }
2199
2200    /// Tell the installed observer, if it is still alive, that a swap promoted
2201    /// `registration`.
2202    fn notify_swap_promoted(&self, registration: &crate::registry::ModuleRegistration) {
2203        let observer = self
2204            .promotion_observer
2205            .0
2206            .lock()
2207            .unwrap_or_else(|poisoned| poisoned.into_inner())
2208            .as_ref()
2209            .and_then(std::sync::Weak::upgrade);
2210        if let Some(observer) = observer {
2211            observer.swap_promoted(registration);
2212        }
2213    }
2214
2215    /// Whether a swap is open for `module_id`.
2216    pub(crate) fn swap_open(&self, module_id: &str) -> bool {
2217        self.swaps
2218            .lock()
2219            .unwrap_or_else(|poisoned| poisoned.into_inner())
2220            .contains_key(module_id)
2221    }
2222
2223    /// Make the candidate's nonce the module's recorded spawn nonce, as a plain
2224    /// respawn would, once cutover has made the candidate the module's process.
2225    /// The swap stays open so the incumbent's nonce keeps attesting until the
2226    /// incumbent has drained and exited.
2227    fn promote_swap_nonce(&self, module_id: &str, reserved: bool) {
2228        let candidate_nonce = self
2229            .swaps
2230            .lock()
2231            .unwrap_or_else(|poisoned| poisoned.into_inner())
2232            .get(module_id)
2233            .map(|swap| swap.candidate_nonce.clone());
2234        let Some(nonce) = candidate_nonce else {
2235            return;
2236        };
2237        self.set_spawn_nonce(module_id, nonce.clone());
2238        if reserved {
2239            self.set_reserved_nonce(module_id, nonce);
2240        }
2241    }
2242
2243    /// The swap gate for a HELLO claiming `module_id`.
2244    ///
2245    /// This runs BEFORE the reserved-module gate. A reserved module's candidate
2246    /// presents the candidate nonce, which the reserved gate (holding the
2247    /// incumbent's nonce) would refuse as `reserved_module` before swap
2248    /// admission was ever reached. And it applies to unreserved ids too: for an
2249    /// unreserved id the only thing that ever stopped a second process claiming
2250    /// a live id was the `duplicate_module_id` refusal, which is exactly the
2251    /// refusal a swap lifts for its candidate.
2252    ///
2253    /// The incumbent's own nonce falls through to the ordinary gates, which
2254    /// treat it as they always have (a live incumbent is refused as a
2255    /// duplicate). Anything else while a swap is open is refused, including an
2256    /// absent nonce.
2257    pub(crate) fn swap_hello_admission(
2258        &self,
2259        module_id: &str,
2260        presented: Option<&str>,
2261    ) -> SwapHelloAdmission {
2262        let swaps = self
2263            .swaps
2264            .lock()
2265            .unwrap_or_else(|poisoned| poisoned.into_inner());
2266        let Some(swap) = swaps.get(module_id) else {
2267            return SwapHelloAdmission::NotSwapping;
2268        };
2269        let Some(presented) = presented else {
2270            return SwapHelloAdmission::Refused;
2271        };
2272        if constant_time_eq(swap.candidate_nonce.as_bytes(), presented.as_bytes()) {
2273            return if swap.candidate_admitted {
2274                SwapHelloAdmission::Refused
2275            } else {
2276                SwapHelloAdmission::Candidate
2277            };
2278        }
2279        if swap
2280            .incumbent_nonce
2281            .as_deref()
2282            .is_some_and(|incumbent| constant_time_eq(incumbent.as_bytes(), presented.as_bytes()))
2283        {
2284            return SwapHelloAdmission::NotSwapping;
2285        }
2286        SwapHelloAdmission::Refused
2287    }
2288
2289    /// Record that the swap token has registered a candidate, so it admits no
2290    /// second HELLO.
2291    pub(crate) fn mark_swap_candidate_admitted(&self, module_id: &str) {
2292        if let Some(swap) = self
2293            .swaps
2294            .lock()
2295            .unwrap_or_else(|poisoned| poisoned.into_inner())
2296            .get_mut(module_id)
2297        {
2298            swap.candidate_admitted = true;
2299        }
2300    }
2301
2302    /// Test/support lookup for the current launch nonce of a supervised spawn.
2303    pub fn spawn_launch_nonce_for(&self, module_id: &str) -> Option<String> {
2304        self.spawn_nonces
2305            .lock()
2306            .unwrap_or_else(|poisoned| poisoned.into_inner())
2307            .get(module_id)
2308            .cloned()
2309    }
2310
2311    /// Test/support lookup for the HELLO-gating nonce of a reserved module.
2312    pub fn reserved_launch_nonce_for(&self, module_id: &str) -> Option<String> {
2313        self.reserved_nonces
2314            .lock()
2315            .unwrap_or_else(|poisoned| poisoned.into_inner())
2316            .get(module_id)
2317            .cloned()
2318            .flatten()
2319    }
2320
2321    pub fn insert(&self, module: SupervisedModule) -> Option<SupervisedModule> {
2322        // Normally already marked before the process was spawned; marking here
2323        // too keeps `configured_ids` a superset of the roster for any caller
2324        // that inserts a module directly.
2325        self.mark_configured(module.module_id());
2326        let mut modules = self
2327            .modules
2328            .lock()
2329            .unwrap_or_else(|poisoned| poisoned.into_inner());
2330        modules.insert(module.module_id().to_string(), module)
2331    }
2332
2333    /// Record that the supervisor has taken on `module_id`. Called before the
2334    /// module's first process is spawned, so that by the time that process can
2335    /// register, [`Self::is_configured`] already answers true.
2336    fn mark_configured(&self, module_id: &str) {
2337        self.configured_ids
2338            .lock()
2339            .unwrap_or_else(|poisoned| poisoned.into_inner())
2340            .insert(module_id.to_string());
2341    }
2342
2343    /// Undo [`Self::mark_configured`] for a module whose first spawn failed
2344    /// before it was ever put on the roster. A module already on the roster
2345    /// keeps its mark: only [`Self::retire`] takes a rostered module off.
2346    fn unmark_configured_unless_rostered(&self, module_id: &str) {
2347        let modules = self
2348            .modules
2349            .lock()
2350            .unwrap_or_else(|poisoned| poisoned.into_inner());
2351        if !modules.contains_key(module_id) {
2352            self.configured_ids
2353                .lock()
2354                .unwrap_or_else(|poisoned| poisoned.into_inner())
2355                .remove(module_id);
2356        }
2357    }
2358
2359    /// Whether `module_id` is a module this daemon supervises: on the roster,
2360    /// or about to be (its process is being spawned right now).
2361    ///
2362    /// This, not `get(..).is_some()`, is what "the owner is configured" means
2363    /// for scopes: a supervised module's process can register and sync before
2364    /// [`Self::get`] can return it, and in that window it is still a module
2365    /// that will sync, not one that never will.
2366    pub(crate) fn is_configured(&self, module_id: &str) -> bool {
2367        self.configured_ids
2368            .lock()
2369            .unwrap_or_else(|poisoned| poisoned.into_inner())
2370            .contains(module_id)
2371    }
2372
2373    pub fn get(&self, module_id: &str) -> Option<SupervisedModule> {
2374        let modules = self
2375            .modules
2376            .lock()
2377            .unwrap_or_else(|poisoned| poisoned.into_inner());
2378        modules.get(module_id).cloned()
2379    }
2380
2381    pub(crate) fn record_late_health_answer(
2382        &self,
2383        module_id: &str,
2384        latency_ms: u64,
2385    ) -> Result<bool, SuperviseError> {
2386        let Some(module) = self.get(module_id) else {
2387            return Ok(false);
2388        };
2389        update_snapshot(&module.inner.snapshot, Some(module_id), |state| {
2390            state.health.late_answer_count = state.health.late_answer_count.saturating_add(1);
2391            state.health.last_late_answer_latency_ms = Some(latency_ms);
2392            // A late answer is an answer: the module served the probe, just past
2393            // the deadline. Leaving the miss streak in place while logging
2394            // "proves the module is alive" is how a CPU-starved module that
2395            // answers every probe a few seconds late still marches to the
2396            // threshold and gets killed — the exact kill class `NoAnswer` is
2397            // excluded from `is_proof_of_death` to prevent. Slow-but-answering
2398            // is degradation, and degradation reports; it does not restart.
2399            state.health.consecutive_failures = 0;
2400        })?;
2401        Ok(true)
2402    }
2403
2404    /// Arm the one-shot marker for the module process that this caller
2405    /// deliberately initiated severance against. Generic connection teardown
2406    /// must not call this:
2407    /// a surviving process would otherwise retain an exemption for a later
2408    /// genuine crash.
2409    pub fn record_deliberate_severance(&self, module_id: &str) -> Result<bool, SuperviseError> {
2410        let Some(module) = self.get(module_id) else {
2411            return Ok(false);
2412        };
2413        let snapshot = lock_snapshot(&module.inner.snapshot)?;
2414        let Some((pid, start_time)) = snapshot.pid.zip(snapshot.process_start_time) else {
2415            return Ok(false);
2416        };
2417        drop(snapshot);
2418        module.record_deliberate_severance(ProcessIdentity { pid, start_time })
2419    }
2420
2421    pub fn list(&self) -> Vec<SupervisedModule> {
2422        let modules = self
2423            .modules
2424            .lock()
2425            .unwrap_or_else(|poisoned| poisoned.into_inner());
2426        let mut modules = modules.values().cloned().collect::<Vec<_>>();
2427        modules.sort_by(|left, right| left.module_id().cmp(right.module_id()));
2428        modules
2429    }
2430
2431    pub(crate) fn retire(&self, module_id: &str) -> Option<SupervisedModule> {
2432        self.spawn_nonces
2433            .lock()
2434            .unwrap_or_else(|poisoned| poisoned.into_inner())
2435            .remove(module_id);
2436        self.close_swap(module_id);
2437        let mut reserved_nonces = self
2438            .reserved_nonces
2439            .lock()
2440            .unwrap_or_else(|poisoned| poisoned.into_inner());
2441        if reserved_nonces.contains_key(module_id) {
2442            // The old nonce must die with the removed process, but the exact-id
2443            // gate remains until an operator explicitly releases it.
2444            reserved_nonces.insert(module_id.to_string(), None);
2445        }
2446        drop(reserved_nonces);
2447        self.reserved_prefix_owners
2448            .lock()
2449            .unwrap_or_else(|poisoned| poisoned.into_inner())
2450            .retain(|_, owner| owner != module_id);
2451        let removed = self
2452            .modules
2453            .lock()
2454            .unwrap_or_else(|poisoned| poisoned.into_inner())
2455            .remove(module_id);
2456        self.configured_ids
2457            .lock()
2458            .unwrap_or_else(|poisoned| poisoned.into_inner())
2459            .remove(module_id);
2460        removed
2461    }
2462
2463    /// Remember a module removed by a non-preview rescan so route.open can
2464    /// distinguish that intentional removal from an unknown id.
2465    pub(crate) fn record_rescan_removal(&self, module_id: &str) {
2466        self.removal_tombstones
2467            .lock()
2468            .unwrap_or_else(|poisoned| poisoned.into_inner())
2469            .insert(module_id.to_string(), unix_ms_now());
2470    }
2471
2472    /// Return how long ago a rescan removed this module in milliseconds.
2473    pub(crate) fn removal_tombstone_age_ms(&self, module_id: &str) -> Option<u64> {
2474        self.removal_tombstones
2475            .lock()
2476            .unwrap_or_else(|poisoned| poisoned.into_inner())
2477            .get(module_id)
2478            .copied()
2479            .map(|removed_at_ms| unix_ms_now().saturating_sub(removed_at_ms))
2480    }
2481
2482    /// Retire a reserved-id gate only after its module has left supervision.
2483    ///
2484    /// A retained gate has no live nonce (`None`), so releasing any other entry
2485    /// would weaken a currently configured or otherwise active reservation.
2486    pub(crate) fn release_retained_reserved_gate(&self, module_id: &str) -> bool {
2487        if self.get(module_id).is_some() {
2488            return false;
2489        }
2490        let mut reserved_nonces = self
2491            .reserved_nonces
2492            .lock()
2493            .unwrap_or_else(|poisoned| poisoned.into_inner());
2494        if !matches!(reserved_nonces.get(module_id), Some(None)) {
2495            return false;
2496        }
2497        reserved_nonces.remove(module_id);
2498        true
2499    }
2500
2501    pub(crate) fn operation_lock(&self) -> Arc<AsyncMutex<()>> {
2502        Arc::clone(&self.operation_lock)
2503    }
2504}
2505
2506/// Process supervisor for subc-owned singleton modules.
2507#[derive(Debug, Clone)]
2508pub struct Supervisor {
2509    registry: Arc<Registry>,
2510    restart_policy: RestartPolicy,
2511    drain_timeout: Duration,
2512    connection_file_path: Option<PathBuf>,
2513    capture_logs_dir: Option<PathBuf>,
2514    forwarding: Option<Arc<ForwardingTable>>,
2515    process_liveness: Arc<SupervisorProcessLiveness>,
2516    supervisor_handle: Option<SupervisorHandle>,
2517    health: HealthConfig,
2518    daemon_start_clock: crate::clock::StartClock,
2519    terminal_journal: Option<Arc<crate::terminal_journal::TerminalJournal>>,
2520    spawn_events: SpawnEventFeed,
2521    provenance_probe: ExecutableIdentityProbe,
2522    /// Every process spawned through this supervisor (and its clones) and not
2523    /// yet reaped, so daemon shutdown can end them.
2524    child_roster: ChildRoster,
2525    #[cfg(target_os = "linux")]
2526    cgroup_placement: Option<subc_cgroup::Placement>,
2527    #[cfg(test)]
2528    test_after_first_spawn: AfterFirstSpawnHook,
2529}
2530
2531/// Test-only hook run on the path that takes on a new module, right after its
2532/// first `spawn_child` returns (the process exists and could already be
2533/// registering) and before that process is handed to the module's supervise
2534/// loop and put on the roster. Lets a test observe what a fast child would see
2535/// in that window without racing a real one.
2536#[cfg(test)]
2537#[derive(Clone, Default)]
2538struct AfterFirstSpawnHook(Option<AfterFirstSpawnFn>);
2539
2540#[cfg(test)]
2541type AfterFirstSpawnFn = Arc<dyn Fn(&str) + Send + Sync>;
2542
2543#[cfg(test)]
2544impl fmt::Debug for AfterFirstSpawnHook {
2545    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2546        f.write_str("AfterFirstSpawnHook")
2547    }
2548}
2549
2550#[cfg(test)]
2551impl AfterFirstSpawnHook {
2552    fn run(&self, module_id: &str) {
2553        if let Some(hook) = &self.0 {
2554            hook(module_id);
2555        }
2556    }
2557}
2558
2559impl Supervisor {
2560    #[cfg(test)]
2561    pub(crate) fn new_for_test(registry: Arc<Registry>, policy: RestartPolicy) -> Self {
2562        let supervisor = Self::new(registry, policy);
2563        #[cfg(target_os = "macos")]
2564        let supervisor = supervisor.with_privacy_trampoline(test_privacy_trampoline());
2565        supervisor
2566    }
2567    /// Verify the trampoline once when configured. Missing private OS support
2568    /// refuses every macOS launch by name but does not stop the daemon's control
2569    /// server. Embedders must explicitly provide a binary with the subc-os hidden
2570    /// entry point; the library must not exec an arbitrary hosting program.
2571    pub fn with_privacy_trampoline(self, path: impl Into<PathBuf>) -> Self {
2572        let path = path.into();
2573        #[cfg(target_os = "macos")]
2574        {
2575            let result = probe_privacy_trampoline(&path).map(|()| path);
2576            if let Err(cause) = &result {
2577                error!(%cause, "macOS privacy identity unavailable; supervised launches will refuse");
2578            }
2579            self.child_roster.set_privacy_trampoline(result);
2580        }
2581        #[cfg(not(target_os = "macos"))]
2582        let _ = path;
2583        self
2584    }
2585    /// The first step of an announced daemon shutdown, before the notice and
2586    /// before any connection is closed.
2587    ///
2588    /// Sets the daemon-shutdown flag first: from here on no module is
2589    /// respawned (crash restart, operator restart, or swap), and every child
2590    /// exit is recorded as `daemon_shutdown` rather than as a crash, whether
2591    /// the module exits on the EOF this shutdown gives it or is signalled by a
2592    /// service manager that kills the whole cgroup. Then writes the journal's
2593    /// shutdown marker, which records the instant and closes this daemon
2594    /// incarnation's stretch of the journal.
2595    #[cfg(unix)]
2596    pub(crate) fn begin_daemon_shutdown(&self) {
2597        self.child_roster.close();
2598        if let Some(journal) = &self.terminal_journal {
2599            journal.stamp_shutdown();
2600        }
2601    }
2602
2603    /// Announce a cut while established connections can still carry replies.
2604    /// These budgets promise notice and a bounded wait, not child completion;
2605    /// they are local policy, not an estimate of launchd's unknown kill ceiling.
2606    #[cfg(unix)]
2607    pub(crate) async fn drain_for_daemon_shutdown(&self) -> Result<(), SuperviseError> {
2608        const NOTICE_BUDGET: Duration = Duration::from_millis(500);
2609        const DRAIN_BUDGET: Duration = Duration::from_secs(2);
2610        let Some(forwarding) = &self.forwarding else {
2611            return Ok(());
2612        };
2613        let module_ids = forwarding
2614            .begin_daemon_drain()
2615            .map_err(SuperviseError::Forwarding)?;
2616        let deadline_ms =
2617            unix_ms_now().saturating_add((NOTICE_BUDGET + DRAIN_BUDGET).as_millis() as u64);
2618        let mut notices = tokio::task::JoinSet::new();
2619        let mut drains = Vec::new();
2620        for module_id in module_ids {
2621            let Some(target) = forwarding
2622                .begin_module_drain(&module_id, RouteCloseReason::Restart)
2623                .map_err(SuperviseError::Forwarding)?
2624            else {
2625                continue;
2626            };
2627            let routes = forwarding
2628                .endpoint_routes(target.endpoint)
2629                .map_err(SuperviseError::Forwarding)?;
2630            // Restart allows deployed consumers to reopen after the new daemon
2631            // appears. The wire reason stays `restart`; what tells a daemon cut
2632            // apart from a module restart afterwards is the terminal record
2633            // itself, whose disposition is `daemon_shutdown` for every exit
2634            // observed once `begin_daemon_shutdown` has run.
2635            let command = serde_json::to_vec(&ModuleControlCommand::Draining {
2636                reason: RouteCloseReason::Restart,
2637                deadline_ms,
2638            })
2639            .expect("module draining serializes");
2640            let mut recipients = vec![(target.sink.clone(), target.negotiated_ver, command)];
2641            let mut clients: Vec<(crate::forwarding::GoodbyeTarget, Vec<u16>)> = Vec::new();
2642            for route in routes {
2643                let client = route.goodbye_target;
2644                if let Some((_, channels)) = clients
2645                    .iter_mut()
2646                    .find(|(existing, _)| existing.connection_id == client.connection_id)
2647                {
2648                    channels.push(client.channel);
2649                } else {
2650                    let channel = client.channel;
2651                    clients.push((client, vec![channel]));
2652                }
2653            }
2654            for (client, mut channels) in clients {
2655                channels.sort_unstable();
2656                channels.dedup();
2657                let closing = serde_json::to_vec(&ClientControlPush::RouteClosing {
2658                    module_id: module_id.clone(),
2659                    channels,
2660                    reason: RouteCloseReason::Restart,
2661                })
2662                .expect("route closing serializes");
2663                recipients.push((client.sink, client.negotiated_ver, closing));
2664            }
2665            for (sink, version, body) in recipients {
2666                notices.spawn(async move {
2667                    let frame = Frame::build_with_version(
2668                        version,
2669                        FrameType::Push,
2670                        control_flags(),
2671                        0,
2672                        0,
2673                        0,
2674                        body,
2675                    )
2676                    .expect("bounded lifecycle notice frame builds");
2677                    sink.send_flushed(frame).await
2678                });
2679            }
2680            let gauges = declared_busy_gauges(&self.registry, &module_id)?;
2681            drains.push((module_id, target.endpoint, gauges));
2682        }
2683        // A quiet forwarding table is not proof that queued notices reached the
2684        // socket. Wait for writer flush acknowledgements before testing quiescence.
2685        let notice_deadline = Instant::now() + NOTICE_BUDGET;
2686        while let Ok(Some(result)) = timeout_at(notice_deadline, notices.join_next()).await {
2687            if !matches!(result, Ok(Ok(()))) {
2688                warn!(?result, "daemon shutdown notice delivery failed");
2689            }
2690        }
2691        notices.abort_all();
2692        let deadline = Instant::now() + DRAIN_BUDGET;
2693        let mut waits = tokio::task::JoinSet::new();
2694        for (module_id, endpoint, gauges) in drains {
2695            let forwarding = Arc::clone(forwarding);
2696            let mut runtime = self.runtime_config();
2697            runtime.health.cadence = Duration::from_millis(100);
2698            waits.spawn(async move {
2699                wait_for_forwarding_quiescence(
2700                    &forwarding,
2701                    &module_id,
2702                    &runtime,
2703                    endpoint,
2704                    deadline,
2705                    &gauges,
2706                    DrainScope::Active,
2707                )
2708                .await
2709            });
2710        }
2711        while let Ok(Some(result)) = timeout_at(deadline, waits.join_next()).await {
2712            if !matches!(result, Ok(Ok(true))) {
2713                warn!(?result, "daemon shutdown drain did not reach quiescence");
2714            }
2715        }
2716        Ok(())
2717    }
2718
2719    /// The last step of an announced daemon shutdown, after the notice and the
2720    /// drain: send every registered module a module GOODBYE, the same planned
2721    /// stop signal `ck module stop` gives, then close every connection so each
2722    /// subc module sees EOF and starts its own teardown, then end every
2723    /// supervised child that has not exited
2724    /// by its own deadline (its drain budget, capped). Modules lead their own
2725    /// process groups, so a
2726    /// service manager's group kill no longer reaches them; without this a
2727    /// child that does not stop on EOF (every `protocol: "none"` child, which
2728    /// has no connection) would outlive the daemon. Every wait is bounded (see
2729    /// `child_roster`), and `escalate` resolving (a second SIGTERM) cuts them.
2730    #[cfg(unix)]
2731    pub(crate) async fn end_children_for_daemon_shutdown(
2732        &self,
2733        already_escalated: bool,
2734        escalate: impl std::future::Future<Output = ()>,
2735    ) {
2736        tokio::pin!(escalate);
2737        let mut escalated = already_escalated;
2738        if let Some(forwarding) = &self.forwarding {
2739            let reason = CloseReason::new(
2740                "daemon_shutdown",
2741                "the daemon is exiting after its shutdown notice and drain",
2742            );
2743            if escalated {
2744                // The operator asked to stop waiting: queue the GOODBYEs but
2745                // do not wait for them to be written.
2746                send_module_goodbyes_for_daemon_shutdown(forwarding, &reason, false).await;
2747            } else {
2748                tokio::select! {
2749                    biased;
2750                    _ = escalate.as_mut() => {
2751                        info!("second SIGTERM: abandoning module GOODBYE delivery");
2752                        escalated = true;
2753                    }
2754                    _ = send_module_goodbyes_for_daemon_shutdown(forwarding, &reason, true) => {}
2755                }
2756            }
2757            let closed = forwarding.close_all_connections(&reason);
2758            debug!(closed, "closed established connections for daemon shutdown");
2759        }
2760        // If the second SIGTERM arrived during GOODBYE delivery, `escalate` has
2761        // already completed and must not be polled again; the child shutdown
2762        // wait is told it is escalated and gets a future that never fires.
2763        let escalated_here = escalated && !already_escalated;
2764        let remaining_escalate = async move {
2765            if escalated_here {
2766                std::future::pending::<()>().await;
2767            } else {
2768                escalate.await;
2769            }
2770        };
2771        crate::child_roster::end_children_for_daemon_shutdown(
2772            &self.child_roster,
2773            escalated,
2774            remaining_escalate,
2775        )
2776        .await;
2777    }
2778
2779    pub fn new(registry: Arc<Registry>, restart_policy: RestartPolicy) -> Self {
2780        Self {
2781            registry,
2782            restart_policy,
2783            drain_timeout: DEFAULT_DRAIN_TIMEOUT,
2784            connection_file_path: None,
2785            capture_logs_dir: None,
2786            forwarding: None,
2787            process_liveness: Arc::new(SupervisorProcessLiveness::default()),
2788            supervisor_handle: None,
2789            health: HealthConfig::default(),
2790            daemon_start_clock: crate::clock::StartClock::capture(),
2791            terminal_journal: None,
2792            spawn_events: SpawnEventFeed::default(),
2793            provenance_probe: ExecutableIdentityProbe::default(),
2794            child_roster: ChildRoster::default(),
2795            #[cfg(target_os = "linux")]
2796            cgroup_placement: None,
2797            #[cfg(test)]
2798            test_after_first_spawn: AfterFirstSpawnHook::default(),
2799        }
2800    }
2801
2802    pub fn with_drain_timeout(mut self, drain_timeout: Duration) -> Self {
2803        self.drain_timeout = drain_timeout;
2804        self
2805    }
2806
2807    pub fn with_process_liveness(
2808        mut self,
2809        process_liveness: Arc<SupervisorProcessLiveness>,
2810    ) -> Self {
2811        self.process_liveness = process_liveness;
2812        self
2813    }
2814
2815    pub fn with_connection_file_path(mut self, connection_file_path: impl Into<PathBuf>) -> Self {
2816        self.connection_file_path = Some(connection_file_path.into());
2817        self
2818    }
2819
2820    /// Enables daemon-owned capture files for supervised stdout and stderr.
2821    pub fn with_capture_logs_dir(mut self, logs_dir: impl Into<PathBuf>) -> Self {
2822        self.capture_logs_dir = Some(logs_dir.into());
2823        self
2824    }
2825
2826    /// Names this daemon lifetime in spawn events, independently of whether a
2827    /// terminal journal is configured.
2828    pub fn with_daemon_incarnation(self, daemon_incarnation: String) -> Self {
2829        // A millisecond start stamp can repeat after clock rollback or a rapid
2830        // restart. Use the connection file's random daemon_id instead: it already
2831        // identifies this daemon lifetime independently of the wall clock.
2832        self.spawn_events.configure_incarnation(daemon_incarnation);
2833        self
2834    }
2835
2836    /// Enables best-effort history shared by every supervised module. Without
2837    /// it, terminal history is kept only in each module's in-memory ring.
2838    pub fn with_terminal_journal(self, path: PathBuf, daemon_incarnation: String) -> Self {
2839        let mut this = self.with_daemon_incarnation(daemon_incarnation.clone());
2840        this.terminal_journal = Some(Arc::new(crate::terminal_journal::TerminalJournal::open(
2841            path,
2842            daemon_incarnation,
2843        )));
2844        this
2845    }
2846
2847    pub fn with_forwarding(mut self, forwarding: Arc<ForwardingTable>) -> Self {
2848        self.forwarding = Some(forwarding);
2849        self
2850    }
2851
2852    pub fn with_handle(mut self, supervisor_handle: SupervisorHandle) -> Self {
2853        self.spawn_events = supervisor_handle.spawn_events.clone();
2854        self.supervisor_handle = Some(supervisor_handle);
2855        self
2856    }
2857
2858    pub fn with_health_config(mut self, health: HealthConfig) -> Self {
2859        self.health = health;
2860        self
2861    }
2862
2863    /// Keeps a record of every live child at `path`, rewritten on each spawn and
2864    /// reap, for the orphan sweep a later daemon runs at boot. Without it no
2865    /// record is kept.
2866    pub fn with_live_children_record(self, path: impl Into<PathBuf>) -> Self {
2867        self.child_roster.record_to(path.into());
2868        self
2869    }
2870
2871    #[cfg(target_os = "linux")]
2872    pub fn with_cgroup_placement(
2873        mut self,
2874        cgroup_placement: Option<subc_cgroup::Placement>,
2875    ) -> Self {
2876        self.cgroup_placement = cgroup_placement;
2877        self
2878    }
2879
2880    /// Spawn `spec.program` and start monitoring it.
2881    ///
2882    /// The child is expected to parse `--subc <connection-file-path>`, read the
2883    /// TCP+key connection file, authenticate to the already-running listener, and
2884    /// register with channel-0 `HELLO` using `spec.module_id` as its manifest id.
2885    pub fn spawn(&self, spec: ModuleSpec) -> Result<SupervisedModule, SuperviseError> {
2886        validate_spec(&spec)?;
2887        self.establish_identity(&spec);
2888
2889        let runtime = self.runtime_config();
2890        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
2891        let spawned = spawn_child(
2892            &spec,
2893            runtime.connection_file_path.as_deref(),
2894            self.supervisor_handle.as_ref(),
2895            &runtime.stderr_ring,
2896            runtime.capture_logs_dir.as_deref(),
2897            &runtime.child_roster,
2898            #[cfg(target_os = "linux")]
2899            runtime.cgroup_placement.as_ref(),
2900        );
2901        #[cfg(test)]
2902        self.test_after_first_spawn.run(&spec.module_id);
2903        let child = match spawned {
2904            Ok(child) => child,
2905            Err(err) => {
2906                // Unlike the configured paths, a failed `spawn` leaves nothing
2907                // on the roster, so the module must not stay marked configured.
2908                self.abandon_unrostered(&spec.module_id);
2909                return Err(err);
2910            }
2911        };
2912        self.mark_first_process_running(&spec, &runtime, &snapshot, &child)?;
2913
2914        Ok(self.supervised_module(spec, runtime, snapshot, Some(child)))
2915    }
2916
2917    /// Make `spec`'s module count as configured, with its identity gates
2918    /// (reserved id, reserved prefixes) in place, BEFORE any process of it
2919    /// exists.
2920    ///
2921    /// Every path that takes on a new module calls this before `spawn_child`.
2922    /// The order is the point: the child can connect, register, sync its
2923    /// scopes and ask about them as soon as it is spawned, and the module is
2924    /// only put on the roster after `spawn_child` returns. Were the mark set
2925    /// with the roster entry, a fast child would see its own owner reported
2926    /// as not configured, and a scoped `route.open` in that window would be
2927    /// refused as terminal `scope_not_live` ("will never sync") instead of
2928    /// retryable `scope_not_synced`.
2929    fn establish_identity(&self, spec: &ModuleSpec) {
2930        if let Some(supervisor_handle) = &self.supervisor_handle {
2931            supervisor_handle.apply_identity_configuration(spec);
2932            supervisor_handle.mark_configured(&spec.module_id);
2933        }
2934    }
2935
2936    /// Take back [`Self::establish_identity`]'s configured mark when the
2937    /// module will not be put on the roster after all.
2938    fn abandon_unrostered(&self, module_id: &str) {
2939        if let Some(supervisor_handle) = &self.supervisor_handle {
2940            supervisor_handle.unmark_configured_unless_rostered(module_id);
2941        }
2942    }
2943
2944    /// Record a freshly spawned first process as running. On failure the
2945    /// module never reaches the roster, so its configured mark is taken back.
2946    fn mark_first_process_running(
2947        &self,
2948        spec: &ModuleSpec,
2949        runtime: &SupervisorRuntimeConfig,
2950        snapshot: &SharedSnapshot,
2951        child: &SupervisedChild,
2952    ) -> Result<(), SuperviseError> {
2953        if let Err(err) = set_running(snapshot, child, &spec.module_id, &runtime.spawn_events) {
2954            self.abandon_unrostered(&spec.module_id);
2955            return Err(err);
2956        }
2957        self.process_liveness
2958            .track(spec.module_id.clone(), Arc::clone(snapshot));
2959        Ok(())
2960    }
2961
2962    /// Start supervising a module declared in daemon configuration.
2963    ///
2964    /// Unlike [`Self::spawn`], this records disabled modules and immediate spawn
2965    /// failures in the supervisor handle so operator-facing `supervisor.list`
2966    /// reflects every configured module while daemon startup continues.
2967    pub fn supervise_configured(
2968        &self,
2969        spec: ModuleSpec,
2970        enabled: bool,
2971    ) -> Result<SupervisedModule, SuperviseError> {
2972        validate_spec(&spec)?;
2973        self.establish_identity(&spec);
2974
2975        let runtime = self.runtime_config();
2976        if !enabled {
2977            let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::disabled()));
2978            return Ok(self.supervised_module(spec, runtime, snapshot, None));
2979        }
2980
2981        #[cfg(target_os = "linux")]
2982        if let Some(pid) = runtime.child_roster.previous_instance_pid(&spec.module_id) {
2983            let mut snapshot = SupervisorSnapshot::starting();
2984            snapshot.waiting_for_previous_pid = Some(pid);
2985            return Ok(self.supervised_module(spec, runtime, Arc::new(Mutex::new(snapshot)), None));
2986        }
2987
2988        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
2989        let spawned = spawn_child(
2990            &spec,
2991            runtime.connection_file_path.as_deref(),
2992            self.supervisor_handle.as_ref(),
2993            &runtime.stderr_ring,
2994            runtime.capture_logs_dir.as_deref(),
2995            &runtime.child_roster,
2996            #[cfg(target_os = "linux")]
2997            runtime.cgroup_placement.as_ref(),
2998        );
2999        #[cfg(test)]
3000        self.test_after_first_spawn.run(&spec.module_id);
3001        match spawned {
3002            Ok(child) => {
3003                self.mark_first_process_running(&spec, &runtime, &snapshot, &child)?;
3004                Ok(self.supervised_module(spec, runtime, snapshot, Some(child)))
3005            }
3006            Err(err) => {
3007                error!(
3008                    module_id = %spec.module_id,
3009                    program = %spec.program.display(),
3010                    error = %err,
3011                    "configured module failed to spawn; marking failed and continuing"
3012                );
3013                let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::failed()));
3014                Ok(self.supervised_module(spec, runtime, snapshot, None))
3015            }
3016        }
3017    }
3018
3019    /// Supervise a configured module with its own health, drain, and crash
3020    /// budget. The restart policy is per-module because the config file is:
3021    /// `modules.<id>.restart` resolves to a full policy at parse time, and a
3022    /// module that is expensive to restart should not be forced onto the same
3023    /// budget as one that is cheap.
3024    pub fn supervise_configured_with_health(
3025        &self,
3026        spec: ModuleSpec,
3027        enabled: bool,
3028        health: HealthConfig,
3029        drain_timeout_ms: Option<u64>,
3030        restart_policy: RestartPolicy,
3031    ) -> Result<SupervisedModule, SuperviseError> {
3032        validate_spec(&spec)?;
3033        self.establish_identity(&spec);
3034
3035        let mut runtime = self.runtime_config();
3036        runtime.health = health.clone();
3037        runtime.restart_policy = restart_policy;
3038        if let Some(ms) = drain_timeout_ms {
3039            runtime.drain_timeout = Duration::from_millis(ms);
3040            *runtime
3041                .effective_drain_timeout
3042                .lock()
3043                .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.drain_timeout;
3044        }
3045        if !enabled {
3046            let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::disabled()));
3047            return Ok(self.supervised_module(spec, runtime, snapshot, None));
3048        }
3049
3050        #[cfg(target_os = "linux")]
3051        if let Some(pid) = runtime.child_roster.previous_instance_pid(&spec.module_id) {
3052            let mut snapshot = SupervisorSnapshot::starting();
3053            snapshot.waiting_for_previous_pid = Some(pid);
3054            return Ok(self.supervised_module(spec, runtime, Arc::new(Mutex::new(snapshot)), None));
3055        }
3056
3057        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
3058        let spawned = spawn_child(
3059            &spec,
3060            runtime.connection_file_path.as_deref(),
3061            self.supervisor_handle.as_ref(),
3062            &runtime.stderr_ring,
3063            runtime.capture_logs_dir.as_deref(),
3064            &runtime.child_roster,
3065            #[cfg(target_os = "linux")]
3066            runtime.cgroup_placement.as_ref(),
3067        );
3068        #[cfg(test)]
3069        self.test_after_first_spawn.run(&spec.module_id);
3070        match spawned {
3071            Ok(child) => {
3072                self.mark_first_process_running(&spec, &runtime, &snapshot, &child)?;
3073                Ok(self.supervised_module(spec, runtime, snapshot, Some(child)))
3074            }
3075            Err(err) => {
3076                if health.critical {
3077                    error!(
3078                        module_id = %spec.module_id,
3079                        program = %spec.program.display(),
3080                        error = %err,
3081                        "critical configured module failed to spawn; marking failed and alerting"
3082                    );
3083                } else {
3084                    error!(
3085                        module_id = %spec.module_id,
3086                        program = %spec.program.display(),
3087                        error = %err,
3088                        "configured module failed to spawn; marking failed and continuing"
3089                    );
3090                }
3091                let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::failed()));
3092                Ok(self.supervised_module(spec, runtime, snapshot, None))
3093            }
3094        }
3095    }
3096
3097    fn runtime_config(&self) -> SupervisorRuntimeConfig {
3098        let effective_drain_timeout = Arc::new(Mutex::new(self.drain_timeout));
3099        SupervisorRuntimeConfig {
3100            scheduled_respawn: Arc::default(),
3101            deferred_reload_reply: Arc::default(),
3102            restart_policy: self.restart_policy,
3103            drain_timeout: self.drain_timeout,
3104            // Shared with this module's roster copy: daemon shutdown waits on
3105            // each child for the module's own drain budget, as resolved now.
3106            child_roster: self
3107                .child_roster
3108                .for_module(Arc::clone(&effective_drain_timeout)),
3109            effective_drain_timeout,
3110            default_drain_timeout: self.drain_timeout,
3111            health: self.health.clone(),
3112            connection_file_path: self.connection_file_path.clone(),
3113            capture_logs_dir: self.capture_logs_dir.clone(),
3114            forwarding: self.forwarding.clone(),
3115            supervisor_handle: self.supervisor_handle.clone(),
3116            stderr_ring: Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default()))),
3117            terminal_ring: Arc::new(Mutex::new(
3118                TerminalRing::new(
3119                    TerminalRingConfig::default(),
3120                    self.daemon_start_clock.started_at_ms(),
3121                )
3122                .with_start_clock(self.daemon_start_clock)
3123                .with_journal(self.terminal_journal.clone())
3124                .with_daemon_shutdown(self.child_roster.shutdown_flag()),
3125            )),
3126            spawn_events: self.spawn_events.clone(),
3127            #[cfg(target_os = "linux")]
3128            cgroup_placement: self.cgroup_placement.clone(),
3129            #[cfg(test)]
3130            test_seed_stale_facts_before_enable_spawn: false,
3131            #[cfg(test)]
3132            test_reload_exit_record_gate: None,
3133        }
3134    }
3135
3136    fn supervised_module(
3137        &self,
3138        spec: ModuleSpec,
3139        runtime: SupervisorRuntimeConfig,
3140        snapshot: SharedSnapshot,
3141        child: Option<SupervisedChild>,
3142    ) -> SupervisedModule {
3143        let configuration = Arc::new(Mutex::new(SupervisedConfiguration {
3144            spec: spec.clone(),
3145            health: runtime.health.clone(),
3146        }));
3147        let stderr_ring = Arc::clone(&runtime.stderr_ring);
3148        let terminal_ring = Arc::clone(&runtime.terminal_ring);
3149        // The module's OWN policy, which may be its per-module config rather than
3150        // the supervisor-wide one; status must report the budget the supervise
3151        // loop actually enforces.
3152        let restart_policy = runtime.restart_policy;
3153        let effective_drain_timeout = Arc::clone(&runtime.effective_drain_timeout);
3154        let (tx, rx) = mpsc::channel(4);
3155        let monitor = tokio::spawn(supervise_loop(
3156            spec.clone(),
3157            runtime,
3158            Arc::clone(&self.registry),
3159            Arc::clone(&self.process_liveness),
3160            Arc::clone(&snapshot),
3161            child,
3162            rx,
3163        ));
3164
3165        let module_id = spec.module_id.clone();
3166        let module = SupervisedModule {
3167            inner: Arc::new(SupervisedModuleInner {
3168                module_id: module_id.clone(),
3169                registry: Arc::clone(&self.registry),
3170                snapshot,
3171                configuration,
3172                stderr_ring,
3173                terminal_ring,
3174                commands: tx,
3175                monitor: Mutex::new(Some(monitor)),
3176                restart_policy,
3177                effective_drain_timeout,
3178                provenance_probe: self.provenance_probe.clone(),
3179            }),
3180        };
3181        // The identity gates and the configured mark were set by
3182        // `establish_identity` before any process was spawned; only the roster
3183        // entry waits for the module handle, which needs the spawned child.
3184        if let Some(supervisor_handle) = &self.supervisor_handle {
3185            supervisor_handle.insert(module.clone());
3186        }
3187        module
3188    }
3189}
3190
3191impl Default for Supervisor {
3192    fn default() -> Self {
3193        Self::new(Arc::new(Registry::default()), RestartPolicy::default())
3194    }
3195}
3196
3197/// Handle to one supervised child process.
3198#[derive(Clone)]
3199pub struct SupervisedModule {
3200    inner: Arc<SupervisedModuleInner>,
3201}
3202
3203struct SupervisedModuleInner {
3204    module_id: String,
3205    registry: Arc<Registry>,
3206    snapshot: SharedSnapshot,
3207    configuration: Arc<Mutex<SupervisedConfiguration>>,
3208    stderr_ring: Arc<Mutex<StderrRing>>,
3209    terminal_ring: Arc<Mutex<TerminalRing>>,
3210    commands: mpsc::Sender<SupervisorCommand>,
3211    monitor: Mutex<Option<JoinHandle<()>>>,
3212    /// Copied from the supervisor's runtime config at spawn so `status()` can
3213    /// report the restart budget without reaching back into the supervisor. The
3214    /// policy is fixed for the process's lifetime, so a copy cannot drift.
3215    restart_policy: RestartPolicy,
3216    effective_drain_timeout: Arc<Mutex<Duration>>,
3217    provenance_probe: ExecutableIdentityProbe,
3218}
3219
3220impl fmt::Debug for SupervisedModule {
3221    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3222        f.debug_struct("SupervisedModule")
3223            .field("module_id", &self.inner.module_id)
3224            .field("status", &self.status())
3225            .finish_non_exhaustive()
3226    }
3227}
3228
3229impl SupervisedModule {
3230    pub fn module_id(&self) -> &str {
3231        &self.inner.module_id
3232    }
3233
3234    /// Test-only: put one probe miss on the streak, the way
3235    /// `handle_health_probe_failure` does, so tests can assert what a later
3236    /// event does to the streak without driving the whole probe loop.
3237    #[cfg(test)]
3238    pub(crate) fn record_health_probe_failure_for_test(
3239        &self,
3240        detail: &str,
3241    ) -> Result<(), SuperviseError> {
3242        update_snapshot(&self.inner.snapshot, Some(&self.inner.module_id), |state| {
3243            state.health.consecutive_failures = state.health.consecutive_failures.saturating_add(1);
3244            state.health.detail = Some(detail.to_string());
3245        })
3246    }
3247
3248    pub fn state(&self) -> Result<ModuleState, SuperviseError> {
3249        Ok(lock_snapshot(&self.inner.snapshot)?.state)
3250    }
3251
3252    pub(crate) fn registration_end_reason(
3253        &self,
3254    ) -> Result<Option<RegistrationEndReason>, SuperviseError> {
3255        let snapshot = lock_snapshot(&self.inner.snapshot)?;
3256        Ok(match snapshot.state {
3257            ModuleState::Draining if snapshot.draining_to_replace => {
3258                Some(RegistrationEndReason::SupervisorRestart)
3259            }
3260            ModuleState::Draining => Some(RegistrationEndReason::SupervisorStop),
3261            ModuleState::Restarting if snapshot.draining_to_replace => {
3262                Some(RegistrationEndReason::SupervisorRestart)
3263            }
3264            _ => None,
3265        })
3266    }
3267
3268    /// The module's retained stderr, newest lines last.
3269    ///
3270    /// Deliberately NOT on [`Self::status`]: a bounded tail is kilobytes per
3271    /// module, `supervisor.list` renders every module, and putting it in the
3272    /// shared snapshot would make each status read carry a payload almost nobody
3273    /// asked for. Callers that want the text ask for it.
3274    pub fn stderr_tail(
3275        &self,
3276        max_lines: Option<usize>,
3277        max_bytes: Option<usize>,
3278    ) -> StderrTailSnapshot {
3279        self.inner
3280            .stderr_ring
3281            .lock()
3282            .unwrap_or_else(|poisoned| poisoned.into_inner())
3283            .snapshot(max_lines, max_bytes)
3284    }
3285
3286    /// The module's bounded terminal history, oldest retained exit first.
3287    ///
3288    /// The daemon-start stamp distinguishes a quiet supervisor from a replacement
3289    /// daemon whose in-memory history was necessarily reset.
3290    pub fn terminal_history(&self) -> TerminalHistorySnapshot {
3291        self.inner
3292            .terminal_ring
3293            .lock()
3294            .unwrap_or_else(|poisoned| poisoned.into_inner())
3295            .snapshot()
3296    }
3297
3298    /// Retained observations from the current ring and all journal generations.
3299    ///
3300    /// Blocking: this reads the journal files. Async callers use
3301    /// [`Self::read_durable_terminal_history`].
3302    pub fn durable_terminal_history(&self) -> subc_control::TerminalHistory {
3303        durable_terminal_history_of(&self.inner.terminal_ring, &self.inner.module_id)
3304    }
3305
3306    /// [`Self::durable_terminal_history`] on a blocking thread, so the journal
3307    /// read (up to every retained generation) never occupies a runtime worker.
3308    /// Fails only if the blocking task could not finish (runtime shutdown or a
3309    /// panic in the read).
3310    pub(crate) async fn read_durable_terminal_history(
3311        &self,
3312    ) -> Result<subc_control::TerminalHistory, tokio::task::JoinError> {
3313        let terminal_ring = Arc::clone(&self.inner.terminal_ring);
3314        let module_id = self.inner.module_id.clone();
3315        tokio::task::spawn_blocking(move || durable_terminal_history_of(&terminal_ring, &module_id))
3316            .await
3317    }
3318
3319    pub fn status(&self) -> Result<ModuleStatus, SuperviseError> {
3320        self.status_with_snapshot_lock(&self.inner.snapshot, None)
3321            .map(|(status, _)| status)
3322    }
3323
3324    pub(crate) fn record_deliberate_severance(
3325        &self,
3326        identity: ProcessIdentity,
3327    ) -> Result<bool, SuperviseError> {
3328        let mut snapshot = lock_snapshot(&self.inner.snapshot)?;
3329        if snapshot.pid != Some(identity.pid)
3330            || snapshot.process_start_time != Some(identity.start_time)
3331        {
3332            return Ok(false);
3333        }
3334        snapshot.deliberate_severance = Some(identity);
3335        Ok(true)
3336    }
3337
3338    /// Read status for a channel-0 renderer and report a contended snapshot lock.
3339    ///
3340    /// Internal supervision callers use [`Self::status`] so writer-side machinery
3341    /// does not produce reader-observability logs.
3342    pub(crate) fn status_for_control(
3343        &self,
3344        caller: &'static str,
3345    ) -> Result<ModuleStatus, SuperviseError> {
3346        self.status_with_snapshot_lock(&self.inner.snapshot, Some(caller))
3347            .map(|(status, _)| status)
3348    }
3349
3350    fn status_with_snapshot_lock(
3351        &self,
3352        snapshot: &SharedSnapshot,
3353        caller: Option<&'static str>,
3354    ) -> Result<(ModuleStatus, Option<SpawnedFileIdentity>), SuperviseError> {
3355        let mut guard = match caller {
3356            Some(caller) => lock_snapshot_for_control(snapshot, &self.inner.module_id, caller)?,
3357            None => lock_snapshot(snapshot)?,
3358        };
3359        // Read the budget through the pruning path so a reader sees the same
3360        // in-window count the restart decision would use, not a stale total.
3361        let restart_count =
3362            guard.crash_restarts_in_window(self.inner.restart_policy.window, Instant::now());
3363        let snapshot = guard.clone();
3364        drop(guard);
3365        let drain_timeout = *self.inner.effective_drain_timeout.lock().map_err(|_| {
3366            SuperviseError::StatePoisoned {
3367                module_id: Some(self.inner.module_id.clone()),
3368            }
3369        })?;
3370        let registration_active = self
3371            .inner
3372            .registry
3373            .get_module(&self.inner.module_id)
3374            .map_err(SuperviseError::Registry)?
3375            .is_some();
3376        let protocol = snapshot
3377            .spawned_protocol
3378            .unwrap_or(self.declared_protocol()?);
3379        let running_process =
3380            snapshot.enabled && snapshot.state == ModuleState::Running && snapshot.process_alive;
3381        // Registration is the difference between the two protocols and the only
3382        // one: a subc module that has not registered cannot serve a request even
3383        // though its process is up, and a `none` module never registers at all,
3384        // so requiring it there would pin `live` to false for the whole life of
3385        // a perfectly healthy process.
3386        let live = match protocol {
3387            ModuleProtocol::Subc => running_process && registration_active,
3388            ModuleProtocol::None => running_process,
3389        };
3390
3391        Ok((
3392            ModuleStatus {
3393                waiting_for_previous_pid: snapshot.waiting_for_previous_pid,
3394                module_id: self.inner.module_id.clone(),
3395                launch_nonce_source: observed_launch_nonce_source(&snapshot),
3396                state: snapshot.state,
3397                enabled: snapshot.enabled,
3398                process_alive: snapshot.process_alive,
3399                registration_active,
3400                protocol,
3401                live,
3402                restart_count,
3403                lifetime_restarts: snapshot.lifetime_restarts,
3404                spawn_generation: snapshot.spawn_generation,
3405                max_restarts: self.inner.restart_policy.max_restarts,
3406                restart_window: self.inner.restart_policy.window,
3407                drain_timeout,
3408                restart_backoff: self.inner.restart_policy.backoff,
3409                restart_max_backoff: self.inner.restart_policy.max_backoff,
3410                pid: snapshot.reported_pid(),
3411                spawned_at_ms: snapshot.spawned_at_ms,
3412                spawned_from: snapshot.spawned_from,
3413                process_start_time: snapshot.process_start_time,
3414                last_exit: snapshot.last_exit,
3415                health: snapshot.health,
3416            },
3417            snapshot.spawned_file_identity.clone(),
3418        ))
3419    }
3420
3421    #[cfg(test)]
3422    pub(crate) fn hold_snapshot_for_test(
3423        &self,
3424        acquired: std::sync::mpsc::Sender<()>,
3425        hold: Duration,
3426    ) -> std::thread::JoinHandle<()> {
3427        let snapshot = Arc::clone(&self.inner.snapshot);
3428        std::thread::spawn(move || {
3429            let _guard = snapshot.lock().expect("test snapshot lock is not poisoned");
3430            acquired
3431                .send(())
3432                .expect("test receiver waits for snapshot lock");
3433            std::thread::sleep(hold);
3434        })
3435    }
3436
3437    /// The status and the running-image check for `supervisor.provenance`,
3438    /// taken from one status read. The exec acknowledgement can land between
3439    /// two separate reads, and the reply would then pair "no pid yet" with an
3440    /// image observed after the module started, which describes no single
3441    /// moment.
3442    pub(crate) async fn status_and_running_image_agreement(
3443        &self,
3444    ) -> Result<(ModuleStatus, subc_control::RunningImageAgreement), SuperviseError> {
3445        let (status, identity) = self.status_with_snapshot_lock(&self.inner.snapshot, None)?;
3446        let image = self
3447            .inner
3448            .provenance_probe
3449            .observe(
3450                status.pid,
3451                status.spawned_from.as_deref(),
3452                identity,
3453                status.process_start_time,
3454            )
3455            .await;
3456        Ok((status, image))
3457    }
3458
3459    pub(crate) async fn running_image_agreement(&self) -> subc_control::RunningImageAgreement {
3460        let snapshot = match lock_snapshot(&self.inner.snapshot) {
3461            Ok(snapshot) => snapshot.clone(),
3462            Err(_) => {
3463                return subc_control::RunningImageAgreement::Unavailable {
3464                    reason: subc_control::RunningImageUnavailableReason::NotRunning,
3465                };
3466            }
3467        };
3468        self.inner
3469            .provenance_probe
3470            .observe(
3471                snapshot.reported_pid(),
3472                snapshot.spawned_from.as_deref(),
3473                snapshot.spawned_file_identity.clone(),
3474                snapshot.process_start_time,
3475            )
3476            .await
3477    }
3478
3479    /// Memory and CPU time of the module's current process, read now. Only the
3480    /// process the supervisor spawned is read, not processes it has started.
3481    pub(crate) fn child_resource_usage(&self) -> subc_control::ChildResourceUsage {
3482        #[cfg(windows)]
3483        {
3484            let image = lock_snapshot(&self.inner.snapshot)
3485                .ok()
3486                .and_then(|snapshot| {
3487                    snapshot
3488                        .spawned_file_identity
3489                        .as_ref()
3490                        .and_then(|identity| identity.windows_image.clone())
3491                });
3492            match image {
3493                Some(image) => {
3494                    crate::child_resources::read_windows_handle(image.process(), image.start_time())
3495                }
3496                None => subc_control::ChildResourceUsage::Unavailable {
3497                    reason: subc_control::ChildResourceUnavailableReason::NotRunning,
3498                },
3499            }
3500        }
3501        #[cfg(not(windows))]
3502        {
3503            let (pid, start_time) = match lock_snapshot(&self.inner.snapshot) {
3504                Ok(snapshot) => (snapshot.reported_pid(), snapshot.process_start_time),
3505                Err(_) => {
3506                    return subc_control::ChildResourceUsage::Unavailable {
3507                        reason: subc_control::ChildResourceUnavailableReason::Unreadable,
3508                    }
3509                }
3510            };
3511            crate::child_resources::read(pid, start_time)
3512        }
3513    }
3514
3515    pub(crate) fn will_recover_after_connection_loss(&self) -> Result<bool, SuperviseError> {
3516        let mut snapshot = lock_snapshot(&self.inner.snapshot)?;
3517        Ok(match snapshot.state {
3518            ModuleState::Restarting => true,
3519            ModuleState::Failed | ModuleState::Disabled => false,
3520            _ => daemon_will_restart(&mut snapshot, &self.inner.restart_policy, Instant::now()),
3521        })
3522    }
3523
3524    #[cfg(test)]
3525    pub(crate) fn is_warming(&self) -> Result<bool, SuperviseError> {
3526        self.is_warming_with_snapshot_lock(None)
3527    }
3528
3529    pub(crate) fn is_warming_for_control(
3530        &self,
3531        caller: &'static str,
3532    ) -> Result<bool, SuperviseError> {
3533        self.is_warming_with_snapshot_lock(Some(caller))
3534    }
3535
3536    fn is_warming_with_snapshot_lock(
3537        &self,
3538        caller: Option<&'static str>,
3539    ) -> Result<bool, SuperviseError> {
3540        let snapshot = match caller {
3541            Some(caller) => {
3542                lock_snapshot_for_control(&self.inner.snapshot, &self.inner.module_id, caller)?
3543            }
3544            None => lock_snapshot(&self.inner.snapshot)?,
3545        }
3546        .clone();
3547        Ok(matches!(
3548            snapshot.state,
3549            ModuleState::Starting | ModuleState::Running | ModuleState::Restarting
3550        ))
3551    }
3552
3553    /// Drain the module and stop monitoring it.
3554    pub async fn drain(&self) -> Result<(), SuperviseError> {
3555        self.stop().await
3556    }
3557
3558    pub(crate) async fn retire(&self) -> Result<(), SuperviseError> {
3559        match self.state()? {
3560            ModuleState::Stopped | ModuleState::Failed => return Ok(()),
3561            ModuleState::Starting
3562            | ModuleState::Running
3563            | ModuleState::Unresponsive
3564            | ModuleState::Restarting
3565            | ModuleState::Draining
3566            | ModuleState::Disabled => {}
3567        }
3568
3569        let (reply_tx, reply_rx) = oneshot::channel();
3570        self.inner
3571            .commands
3572            .send(SupervisorCommand::Retire { reply: reply_tx })
3573            .await
3574            .map_err(|_| SuperviseError::CommandClosed {
3575                module_id: self.inner.module_id.clone(),
3576            })?;
3577        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3578            module_id: self.inner.module_id.clone(),
3579        })?
3580    }
3581
3582    pub async fn stop(&self) -> Result<(), SuperviseError> {
3583        match self.state()? {
3584            ModuleState::Stopped | ModuleState::Failed => return Ok(()),
3585            ModuleState::Starting
3586            | ModuleState::Running
3587            | ModuleState::Unresponsive
3588            | ModuleState::Restarting
3589            | ModuleState::Draining
3590            | ModuleState::Disabled => {}
3591        }
3592
3593        let (reply_tx, reply_rx) = oneshot::channel();
3594        self.inner
3595            .commands
3596            .send(SupervisorCommand::Drain { reply: reply_tx })
3597            .await
3598            .map_err(|_| SuperviseError::CommandClosed {
3599                module_id: self.inner.module_id.clone(),
3600            })?;
3601        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3602            module_id: self.inner.module_id.clone(),
3603        })?
3604    }
3605
3606    pub async fn restart(&self, drain_timeout_ms: Option<u64>) -> Result<(), SuperviseError> {
3607        let received_at_generation = lock_snapshot(&self.inner.snapshot)?.spawn_generation;
3608        let (reply_tx, reply_rx) = oneshot::channel();
3609        self.inner
3610            .commands
3611            .send(SupervisorCommand::Restart {
3612                drain_timeout_ms,
3613                received_at_generation,
3614                queued_at: Instant::now(),
3615                reply: reply_tx,
3616            })
3617            .await
3618            .map_err(|_| SuperviseError::CommandClosed {
3619                module_id: self.inner.module_id.clone(),
3620            })?;
3621        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3622            module_id: self.inner.module_id.clone(),
3623        })?
3624    }
3625
3626    /// Blue/green restart: see [`SupervisorCommand::Swap`] and the
3627    /// `supervisor_swap` module. Returns once the swap has cut over (the old
3628    /// process then drains in the background of the supervise loop) or has
3629    /// failed, leaving the old process serving.
3630    pub async fn swap(&self, ready_timeout: Option<Duration>) -> Result<(), SuperviseError> {
3631        let (reply_tx, reply_rx) = oneshot::channel();
3632        self.inner
3633            .commands
3634            .send(SupervisorCommand::Swap {
3635                ready_timeout,
3636                reply: reply_tx,
3637            })
3638            .await
3639            .map_err(|_| SuperviseError::CommandClosed {
3640                module_id: self.inner.module_id.clone(),
3641            })?;
3642        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3643            module_id: self.inner.module_id.clone(),
3644        })?
3645    }
3646
3647    pub async fn reload(&self) -> Result<(), SuperviseError> {
3648        let (reply_tx, reply_rx) = oneshot::channel();
3649        self.inner
3650            .commands
3651            .send(SupervisorCommand::Reload { reply: reply_tx })
3652            .await
3653            .map_err(|_| SuperviseError::CommandClosed {
3654                module_id: self.inner.module_id.clone(),
3655            })?;
3656        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3657            module_id: self.inner.module_id.clone(),
3658        })?
3659    }
3660
3661    pub async fn set_enabled(&self, enabled: bool) -> Result<bool, SuperviseError> {
3662        let (reply_tx, reply_rx) = oneshot::channel();
3663        self.inner
3664            .commands
3665            .send(SupervisorCommand::SetEnabled {
3666                enabled,
3667                reply: reply_tx,
3668            })
3669            .await
3670            .map_err(|_| SuperviseError::CommandClosed {
3671                module_id: self.inner.module_id.clone(),
3672            })?;
3673        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3674            module_id: self.inner.module_id.clone(),
3675        })?
3676    }
3677
3678    /// The current process's protocol, or the configured protocol when down.
3679    /// A rescan stores the next launch spec without changing how an existing
3680    /// process registers, serves routes, is probed, or exits.
3681    pub(crate) fn declared_protocol(&self) -> Result<ModuleProtocol, SuperviseError> {
3682        let configured = self
3683            .inner
3684            .configuration
3685            .lock()
3686            .map_err(|_| SuperviseError::StatePoisoned {
3687                module_id: Some(self.inner.module_id.clone()),
3688            })?
3689            .spec
3690            .protocol;
3691        let state = lock_snapshot(&self.inner.snapshot)?;
3692        Ok(state.spawned_protocol.unwrap_or(configured))
3693    }
3694
3695    pub(crate) fn configuration(&self) -> Result<(ModuleSpec, HealthConfig), SuperviseError> {
3696        let configuration =
3697            self.inner
3698                .configuration
3699                .lock()
3700                .map_err(|_| SuperviseError::StatePoisoned {
3701                    module_id: Some(self.inner.module_id.clone()),
3702                })?;
3703        Ok((configuration.spec.clone(), configuration.health.clone()))
3704    }
3705
3706    /// Replace this module's launch spec, keeping its health and drain policy,
3707    /// the way a rescan does for a changed config entry. The running process is
3708    /// untouched; the next spawn (a restart, or a swap's candidate) uses it.
3709    #[cfg(any(test, feature = "test-support"))]
3710    pub async fn update_spec_for_test(&self, spec: ModuleSpec) -> Result<(), SuperviseError> {
3711        let (_, health) = self.configuration()?;
3712        let drain_timeout_ms = u64::try_from(
3713            self.inner
3714                .effective_drain_timeout
3715                .lock()
3716                .unwrap_or_else(|poisoned| poisoned.into_inner())
3717                .as_millis(),
3718        )
3719        .ok();
3720        self.update_configuration(spec, health, drain_timeout_ms)
3721            .await
3722    }
3723
3724    pub(crate) async fn update_configuration(
3725        &self,
3726        spec: ModuleSpec,
3727        health: HealthConfig,
3728        drain_timeout_ms: Option<u64>,
3729    ) -> Result<(), SuperviseError> {
3730        if spec.module_id != self.inner.module_id {
3731            return Err(SuperviseError::InvalidSpec {
3732                reason: "a supervised module's module_id cannot be changed".to_string(),
3733            });
3734        }
3735        validate_spec(&spec)?;
3736        let (reply_tx, reply_rx) = oneshot::channel();
3737        self.inner
3738            .commands
3739            .send(SupervisorCommand::UpdateConfiguration {
3740                spec: spec.clone(),
3741                health: health.clone(),
3742                drain_timeout_ms,
3743                reply: reply_tx,
3744            })
3745            .await
3746            .map_err(|_| SuperviseError::CommandClosed {
3747                module_id: self.inner.module_id.clone(),
3748            })?;
3749        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3750            module_id: self.inner.module_id.clone(),
3751        })?;
3752        let mut configuration =
3753            self.inner
3754                .configuration
3755                .lock()
3756                .map_err(|_| SuperviseError::StatePoisoned {
3757                    module_id: Some(self.inner.module_id.clone()),
3758                })?;
3759        configuration.spec = spec;
3760        configuration.health = health;
3761        Ok(())
3762    }
3763}
3764
3765impl Drop for SupervisedModuleInner {
3766    fn drop(&mut self) {
3767        let Ok(mut monitor) = self.monitor.lock() else {
3768            return;
3769        };
3770        if let Some(monitor) = monitor.as_ref().filter(|monitor| !monitor.is_finished()) {
3771            let _ = update_snapshot(&self.snapshot, Some(&self.module_id), |state| {
3772                state.state = ModuleState::Stopped;
3773                clear_current_process_facts(state);
3774            });
3775            monitor.abort();
3776        }
3777        let _ = monitor.take();
3778    }
3779}
3780
3781#[derive(Debug)]
3782enum SupervisorCommand {
3783    Drain {
3784        reply: oneshot::Sender<Result<(), SuperviseError>>,
3785    },
3786    Retire {
3787        reply: oneshot::Sender<Result<(), SuperviseError>>,
3788    },
3789    Restart {
3790        /// Operator override for this one restart's drain budget, in ms. `None`
3791        /// uses the module's configured/default budget; `Some(0)` cuts
3792        /// immediately (wedge bounce: a stuck request never settles, so
3793        /// waiting only delays recovery).
3794        drain_timeout_ms: Option<u64>,
3795        /// The module's `spawn_generation` when the request was received, before
3796        /// it waited in the command queue. A queued restart whose module has
3797        /// since spawned a newer process is already satisfied (see the handler).
3798        received_at_generation: u64,
3799        /// When the request entered the command queue, so the handler can log
3800        /// how long it waited behind the loop's other work.
3801        queued_at: Instant,
3802        reply: oneshot::Sender<Result<(), SuperviseError>>,
3803    },
3804    Reload {
3805        reply: oneshot::Sender<Result<(), SuperviseError>>,
3806    },
3807    SetEnabled {
3808        enabled: bool,
3809        reply: oneshot::Sender<Result<bool, SuperviseError>>,
3810    },
3811    UpdateConfiguration {
3812        spec: ModuleSpec,
3813        health: HealthConfig,
3814        /// Per-module drain override from the new config; `None` re-resolves to
3815        /// the supervisor-wide default.
3816        drain_timeout_ms: Option<u64>,
3817        reply: oneshot::Sender<()>,
3818    },
3819    Swap {
3820        /// How long the candidate may take to register and declare itself
3821        /// ready. `None` uses [`DEFAULT_SWAP_READY_TIMEOUT`].
3822        ready_timeout: Option<Duration>,
3823        /// Answered at cutover or failure; the incumbent's drain follows.
3824        reply: oneshot::Sender<Result<(), SuperviseError>>,
3825    },
3826}
3827
3828#[derive(Debug)]
3829pub enum SuperviseError {
3830    InvalidSpec {
3831        reason: String,
3832    },
3833    Spawn {
3834        program: PathBuf,
3835        source: io::Error,
3836        cgroup_path: Option<PathBuf>,
3837    },
3838    Cgroup {
3839        module_id: String,
3840        source: io::Error,
3841    },
3842    /// CSPRNG failure generating a reserved module's launch nonce. Fail loud rather
3843    /// than spawn a reserved module without its identity binding.
3844    LaunchNonce {
3845        reason: String,
3846    },
3847    Wait {
3848        module_id: String,
3849        source: io::Error,
3850    },
3851    Kill {
3852        module_id: String,
3853        source: io::Error,
3854    },
3855    Forwarding(ForwardingError),
3856    Registry(RegistryError),
3857    ReloadUnavailable {
3858        module_id: String,
3859        reason: String,
3860    },
3861    /// An operator restart/reload was requested for a module that is currently
3862    /// disabled. Restart/reload cycle a *running* module; a disabled module must
3863    /// be explicitly re-enabled (set_enabled(true)) rather than silently started
3864    /// by a restart, so these commands are rejected instead of re-enabling it.
3865    Disabled {
3866        module_id: String,
3867    },
3868    ReloadFailed {
3869        module_id: String,
3870        reason: String,
3871    },
3872    RegistrationStillActive {
3873        module_id: String,
3874        waited: Duration,
3875    },
3876    StatePoisoned {
3877        module_id: Option<String>,
3878    },
3879    CommandClosed {
3880        module_id: String,
3881    },
3882    /// A restart or reload arrived while a swap's candidate was warming. The
3883    /// swap owns the module until it cuts over or fails; a stop or disable
3884    /// would have aborted it instead.
3885    SwapInProgress {
3886        module_id: String,
3887    },
3888    /// A swap was refused before anything was spawned.
3889    SwapRefused {
3890        module_id: String,
3891        reason: SwapRefusal,
3892    },
3893    /// A swap spawned a candidate and gave up on it. The candidate has been
3894    /// killed and its slot freed; the incumbent was left serving and was never
3895    /// drained, except in the one `CutoverLost` case described on that arm.
3896    SwapFailed {
3897        module_id: String,
3898        arm: SwapFailureArm,
3899        detail: String,
3900        /// How the candidate exited, when it exited on its own before the
3901        /// supervisor gave up on it.
3902        candidate_exit: Option<ExitReport>,
3903    },
3904}
3905
3906/// Why a swap was refused before a candidate was spawned.
3907#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3908pub enum SwapRefusal {
3909    /// The module's config does not declare `overlap: "safe"`.
3910    OverlapExclusive,
3911    /// The module is not registered, so there is no incumbent to keep serving
3912    /// and nothing a swap would improve on; a plain restart is the tool.
3913    NotRegistered,
3914    /// The module does not speak the subc wire, so a candidate could never
3915    /// register or declare itself ready.
3916    ProtocolNone,
3917    /// The supervisor lacks the forwarding table (to cut routes over) or the
3918    /// shared handle (to admit the candidate's HELLO) that a swap needs.
3919    NotConfigured,
3920    /// A swap is already open for this module.
3921    AlreadySwapping,
3922}
3923
3924impl SwapRefusal {
3925    pub fn as_str(self) -> &'static str {
3926        match self {
3927            Self::OverlapExclusive => "overlap_exclusive",
3928            Self::NotRegistered => "not_registered",
3929            Self::ProtocolNone => "protocol_none",
3930            Self::NotConfigured => "not_configured",
3931            Self::AlreadySwapping => "already_swapping",
3932        }
3933    }
3934}
3935
3936/// Which failure arm ended a swap. Every arm but one leaves the incumbent
3937/// serving and undrained; see `CutoverLost`.
3938#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3939pub enum SwapFailureArm {
3940    /// The candidate process could not be started.
3941    SpawnFailed,
3942    /// The candidate did not register within the readiness budget.
3943    NeverRegistered,
3944    /// The candidate registered but did not declare itself ready in time.
3945    NeverReady,
3946    /// The candidate exited before cutover.
3947    CandidateExited,
3948    /// The candidate declared itself ready but failed its health probe.
3949    CandidateUnhealthy,
3950    /// An operator stop, disable or retire arrived while the candidate warmed.
3951    /// The candidate was killed and the operator's command then carried out on
3952    /// the incumbent.
3953    Interrupted,
3954    /// The candidate's connection closed at the moment of cutover. If it
3955    /// closed before forwarding moved, the incumbent is untouched. If it closed
3956    /// between the forwarding and registry halves of cutover, forwarding can no
3957    /// longer route to the incumbent, so the module is restarted plainly.
3958    CutoverLost,
3959}
3960
3961impl SwapFailureArm {
3962    pub fn as_str(self) -> &'static str {
3963        match self {
3964            Self::SpawnFailed => "spawn_failed",
3965            Self::NeverRegistered => "never_registered",
3966            Self::NeverReady => "never_ready",
3967            Self::CandidateExited => "candidate_exited",
3968            Self::CandidateUnhealthy => "candidate_unhealthy",
3969            Self::Interrupted => "interrupted",
3970            Self::CutoverLost => "cutover_lost",
3971        }
3972    }
3973}
3974
3975impl fmt::Display for SuperviseError {
3976    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3977        match self {
3978            Self::InvalidSpec { reason } => write!(f, "invalid module spec: {reason}"),
3979            Self::Spawn {
3980                program,
3981                source,
3982                cgroup_path: Some(cgroup_path),
3983            } => write!(
3984                f,
3985                "failed to place module in cgroup '{}' while spawning '{}': {source}",
3986                cgroup_path.display(),
3987                program.display()
3988            ),
3989            Self::Spawn {
3990                program,
3991                source,
3992                cgroup_path: None,
3993            } => write!(
3994                f,
3995                "failed to spawn module '{}': {source}",
3996                program.display()
3997            ),
3998            Self::Cgroup { module_id, source } => {
3999                write!(
4000                    f,
4001                    "failed to prepare cgroup for module '{module_id}': {source}"
4002                )
4003            }
4004            Self::LaunchNonce { reason } => {
4005                write!(
4006                    f,
4007                    "failed to generate reserved-module launch nonce: {reason}"
4008                )
4009            }
4010            Self::Wait { module_id, source } => {
4011                write!(f, "failed to wait for module '{module_id}': {source}")
4012            }
4013            Self::Kill { module_id, source } => {
4014                write!(f, "failed to kill module '{module_id}': {source}")
4015            }
4016            Self::Forwarding(err) => write!(f, "forwarding error: {err}"),
4017            Self::Registry(err) => write!(f, "registry error: {err}"),
4018            Self::ReloadUnavailable { module_id, reason } => {
4019                write!(f, "reload unavailable for module '{module_id}': {reason}")
4020            }
4021            Self::Disabled { module_id } => {
4022                write!(
4023                    f,
4024                    "module '{module_id}' is disabled; enable it before restart or reload"
4025                )
4026            }
4027            Self::ReloadFailed { module_id, reason } => {
4028                write!(f, "reload failed for module '{module_id}': {reason}")
4029            }
4030            Self::RegistrationStillActive { module_id, waited } => write!(
4031                f,
4032                "module '{module_id}' registration remained active after waiting {waited:?}"
4033            ),
4034            Self::StatePoisoned { module_id } => match module_id {
4035                Some(module_id) => {
4036                    write!(f, "supervisor state for module '{module_id}' was poisoned")
4037                }
4038                None => write!(f, "supervisor state was poisoned"),
4039            },
4040            Self::CommandClosed { module_id } => {
4041                write!(
4042                    f,
4043                    "supervisor command channel for module '{module_id}' is closed"
4044                )
4045            }
4046            Self::SwapInProgress { module_id } => write!(
4047                f,
4048                "module '{module_id}' is being swapped; retry once the swap has cut over or failed, or stop the module to abort the swap"
4049            ),
4050            Self::SwapRefused { module_id, reason } => match reason {
4051                SwapRefusal::OverlapExclusive => write!(
4052                    f,
4053                    "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"
4054                ),
4055                SwapRefusal::NotRegistered => write!(
4056                    f,
4057                    "module '{module_id}' is not registered, so there is no serving process to keep while a replacement warms; use a plain restart"
4058                ),
4059                SwapRefusal::ProtocolNone => write!(
4060                    f,
4061                    "module '{module_id}' is protocol: \"none\" and never registers, so a swap could never see its replacement become ready; use a plain restart"
4062                ),
4063                SwapRefusal::NotConfigured => write!(
4064                    f,
4065                    "module '{module_id}' cannot be swapped: the supervisor was built without the forwarding table or shared handle a swap needs"
4066                ),
4067                SwapRefusal::AlreadySwapping => {
4068                    write!(f, "module '{module_id}' is already being swapped")
4069                }
4070            },
4071            Self::SwapFailed {
4072                module_id,
4073                arm,
4074                detail,
4075                ..
4076            } => write!(
4077                f,
4078                "swap of module '{module_id}' failed ({}): {detail}; the running process was left serving",
4079                arm.as_str()
4080            ),
4081        }
4082    }
4083}
4084
4085impl Error for SuperviseError {
4086    fn source(&self) -> Option<&(dyn Error + 'static)> {
4087        match self {
4088            Self::Spawn { source, .. }
4089            | Self::Cgroup { source, .. }
4090            | Self::Wait { source, .. }
4091            | Self::Kill { source, .. } => Some(source),
4092            Self::Forwarding(err) => Some(err),
4093            Self::Registry(err) => Some(err),
4094            Self::LaunchNonce { .. }
4095            | Self::InvalidSpec { .. }
4096            | Self::ReloadUnavailable { .. }
4097            | Self::Disabled { .. }
4098            | Self::ReloadFailed { .. }
4099            | Self::RegistrationStillActive { .. }
4100            | Self::StatePoisoned { .. }
4101            | Self::CommandClosed { .. }
4102            | Self::SwapInProgress { .. }
4103            | Self::SwapRefused { .. }
4104            | Self::SwapFailed { .. } => None,
4105        }
4106    }
4107}
4108
4109pub(crate) fn validate_spec(spec: &ModuleSpec) -> Result<(), SuperviseError> {
4110    if spec.module_id.trim().is_empty() {
4111        return Err(SuperviseError::InvalidSpec {
4112            reason: "module_id must not be empty".to_string(),
4113        });
4114    }
4115
4116    Ok(())
4117}
4118
4119#[derive(Debug, Default)]
4120struct HealthProbeRuntime {
4121    configured_health: Option<HealthConfig>,
4122    registered_connection: Option<crate::ConnectionId>,
4123    advertised: bool,
4124    next_probe_at: Option<Instant>,
4125    probe_index: u64,
4126}
4127
4128fn running_protocol(spec: &ModuleSpec, snapshot: &SharedSnapshot) -> ModuleProtocol {
4129    lock_snapshot(snapshot)
4130        .ok()
4131        .and_then(|state| state.spawned_protocol)
4132        .unwrap_or(spec.protocol)
4133}
4134
4135impl HealthProbeRuntime {
4136    fn refresh_registration(
4137        &mut self,
4138        spec: &ModuleSpec,
4139        runtime: &SupervisorRuntimeConfig,
4140        registry: &Registry,
4141        snapshot: &SharedSnapshot,
4142    ) {
4143        if self.configured_health.as_ref() != Some(&runtime.health) {
4144            self.configured_health = Some(runtime.health.clone());
4145            self.next_probe_at = None;
4146            self.registered_connection = None;
4147            self.probe_index = 0;
4148        }
4149        // A non-wire process never registers. Only an explicitly configured
4150        // HTTP endpoint can arm its health probe; an absent HELLO is not a
4151        // health failure for that kind of process.
4152        if running_protocol(spec, snapshot) == ModuleProtocol::None {
4153            self.registered_connection = None;
4154            self.advertised = runtime.health.http.is_some();
4155            if !self.advertised {
4156                self.next_probe_at = None;
4157                let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4158                    let unknown = ModuleHealthStatus::default();
4159                    if state.health != unknown {
4160                        state.health = unknown;
4161                    }
4162                });
4163            } else if self.next_probe_at.is_none() {
4164                self.next_probe_at = Some(
4165                    Instant::now()
4166                        + jittered_health_delay(&spec.module_id, 0, runtime.health.cadence),
4167                );
4168            }
4169            return;
4170        }
4171
4172        let registration = match registry.get_module(&spec.module_id) {
4173            Ok(registration) => registration,
4174            Err(err) => {
4175                warn!(module_id = %spec.module_id, error = %err, "health prober could not read registry");
4176                self.advertised = false;
4177                self.next_probe_at = None;
4178                return;
4179            }
4180        };
4181
4182        let Some(registration) = registration else {
4183            self.registered_connection = None;
4184            self.advertised = false;
4185            self.next_probe_at = None;
4186            return;
4187        };
4188
4189        let advertised = registration
4190            .control_ops
4191            .iter()
4192            .any(|op| op == MODULE_CONTROL_OP_HEALTH_CHECK);
4193        if !advertised {
4194            self.registered_connection = Some(registration.connection_id);
4195            self.advertised = false;
4196            self.next_probe_at = None;
4197            let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4198                if state.health.status != SupervisorHealthStatus::Unknown
4199                    || state.health.consecutive_failures != 0
4200                    || state.health.last_probe_ms.is_some()
4201                    || state.health.detail.is_some()
4202                    || state.health.metrics.is_some()
4203                {
4204                    state.health.status = SupervisorHealthStatus::Unknown;
4205                    state.health.consecutive_failures = 0;
4206                    state.health.last_probe_ms = None;
4207                    state.health.detail = None;
4208                    state.health.metrics = None;
4209                }
4210            });
4211            return;
4212        }
4213
4214        let reregistered = self.registered_connection != Some(registration.connection_id);
4215        self.registered_connection = Some(registration.connection_id);
4216        self.advertised = true;
4217        if reregistered || self.next_probe_at.is_none() {
4218            self.probe_index = 0;
4219            self.next_probe_at = Some(
4220                Instant::now() + jittered_health_delay(&spec.module_id, 0, runtime.health.cadence),
4221            );
4222            let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4223                state.health.status = SupervisorHealthStatus::Unknown;
4224                state.health.consecutive_failures = 0;
4225                state.health.detail = None;
4226                state.health.metrics = None;
4227            });
4228        }
4229    }
4230
4231    fn wake_after(&self) -> Option<Duration> {
4232        if !self.advertised {
4233            return None;
4234        }
4235        self.next_probe_at
4236            .map(|next| next.saturating_duration_since(Instant::now()))
4237    }
4238
4239    fn due(&self) -> bool {
4240        self.advertised
4241            && self
4242                .next_probe_at
4243                .is_some_and(|next| Instant::now() >= next)
4244    }
4245
4246    fn schedule_next(&mut self, spec: &ModuleSpec, cadence: Duration) {
4247        self.probe_index = self.probe_index.wrapping_add(1);
4248        self.next_probe_at = Some(
4249            Instant::now() + jittered_health_delay(&spec.module_id, self.probe_index, cadence),
4250        );
4251    }
4252}
4253
4254/// What a failed health probe actually OBSERVED, kept apart from how it reads.
4255///
4256/// This was a struct with a single `message: String`, and every one of the
4257/// fifteen construction sites collapsed into it. Each site knows exactly what it
4258/// saw -- the lane is gone, the module did not answer in time, the module
4259/// answered with the wrong thing -- and `handle_health_probe_failure` then
4260/// treated all of them identically: increment a counter, compare to a threshold,
4261/// restart the module. THE DISTINCTION EXISTED AT EVERY CALL SITE AND WAS
4262/// DESTROYED BEFORE THE DECISION THAT NEEDED IT.
4263///
4264/// The distinction that matters is not severity, it is EVIDENTIAL WEIGHT:
4265///
4266/// * `LaneDead` is PROOF. The module's control connection is gone; nothing will
4267///   answer on it again.
4268/// * `NoAnswer` is ABSENCE OF EVIDENCE. It is consistent with a wedged module
4269///   AND with a perfectly healthy one that lost a CPU race -- which is what
4270///   happens under machine load, and is how this supervisor killed a healthy
4271///   module three times in one day.
4272/// * `BadAnswer` proves the module is ALIVE. It replied; the reply was wrong.
4273///   Restarting on it is defensible, but it is not the silence case and should
4274///   never be counted as one.
4275/// * `Misconfigured` is a daemon-side fault. The module has not been asked
4276///   anything, so it cannot be evidence about the module at all.
4277///
4278/// The asymmetry is the whole point: under saturation the WEAKEST signal is the
4279/// one that fires most often, and while every variant collapsed into one string
4280/// it carried the same weight as the strongest.
4281///
4282/// LIVE BEHAVIOUR TODAY, stated here because this doc block describes the
4283/// DESIGN and a reader stopping at it gets the build backwards: the restart
4284/// decision does NOT yet consult this classification -- consecutive `NoAnswer`
4285/// probes still increment the failure streak and drive escalation at the
4286/// threshold (see `is_proof_of_death` below for why that is deliberate and
4287/// what gates the change). Absence of evidence restarts modules today.
4288#[derive(Debug)]
4289enum HealthProbeEvidence {
4290    /// The module's control lane is gone. Proof of death.
4291    LaneDead,
4292    /// No reply within the deadline. Proves nothing about the module's state.
4293    NoAnswer,
4294    /// The module replied, but not with a usable health report. Proves it is alive.
4295    BadAnswer,
4296    /// The daemon could not ask. Says nothing about the module.
4297    Misconfigured,
4298}
4299
4300#[derive(Debug)]
4301struct HealthProbeError {
4302    evidence: HealthProbeEvidence,
4303    message: String,
4304}
4305
4306impl HealthProbeError {
4307    fn lane_dead(message: impl Into<String>) -> Self {
4308        Self::with(HealthProbeEvidence::LaneDead, message)
4309    }
4310
4311    fn no_answer(message: impl Into<String>) -> Self {
4312        Self::with(HealthProbeEvidence::NoAnswer, message)
4313    }
4314
4315    fn bad_answer(message: impl Into<String>) -> Self {
4316        Self::with(HealthProbeEvidence::BadAnswer, message)
4317    }
4318
4319    fn misconfigured(message: impl Into<String>) -> Self {
4320        Self::with(HealthProbeEvidence::Misconfigured, message)
4321    }
4322
4323    fn with(evidence: HealthProbeEvidence, message: impl Into<String>) -> Self {
4324        Self {
4325            evidence,
4326            message: message.into(),
4327        }
4328    }
4329
4330    /// Whether this observation is proof the module cannot serve.
4331    ///
4332    /// Only `LaneDead` qualifies. `NoAnswer` is deliberately excluded: it is the
4333    /// variant that fires under CPU starvation, and treating it as proof is the
4334    /// defect this enum exists to make impossible to reintroduce silently.
4335    ///
4336    /// NOT YET CONSULTED BY THE RESTART DECISION, deliberately. Requiring proof
4337    /// to restart also needs a bound for the case it excludes -- a genuinely
4338    /// wedged module, alive but never answering -- and that bound must come from
4339    /// the distribution of real late-answer latencies, which nothing measures
4340    /// yet. Landing the classification first makes the later change a one-line
4341    /// decision against evidence that already exists, rather than two unproven
4342    /// changes at once.
4343    #[allow(dead_code)]
4344    fn is_proof_of_death(&self) -> bool {
4345        matches!(self.evidence, HealthProbeEvidence::LaneDead)
4346    }
4347
4348    /// Short stable label for logs and the health snapshot.
4349    ///
4350    /// An operator reading `ck health` currently cannot tell "the module is gone"
4351    /// from "the module did not answer in five seconds", because both render as
4352    /// prose in the same field. These labels are what make the two
4353    /// distinguishable at a glance, and they are what a later restart-policy
4354    /// change will be argued from.
4355    fn label(&self) -> &'static str {
4356        match self.evidence {
4357            HealthProbeEvidence::LaneDead => "lane-dead",
4358            HealthProbeEvidence::NoAnswer => "no-answer",
4359            HealthProbeEvidence::BadAnswer => "bad-answer",
4360            HealthProbeEvidence::Misconfigured => "daemon-misconfigured",
4361        }
4362    }
4363}
4364
4365impl fmt::Display for HealthProbeError {
4366    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4367        f.write_str(&self.message)
4368    }
4369}
4370
4371async fn run_health_probe_cycle(
4372    spec: &ModuleSpec,
4373    runtime: &SupervisorRuntimeConfig,
4374    registry: &Registry,
4375    process_liveness: &SupervisorProcessLiveness,
4376    snapshot: &SharedSnapshot,
4377    child: &mut Option<SupervisedChild>,
4378) {
4379    let now_ms = unix_ms_now();
4380    let http = (running_protocol(spec, snapshot) == ModuleProtocol::None)
4381        .then_some(runtime.health.http.as_deref())
4382        .flatten();
4383    let result = match http {
4384        Some(url) => probe_http_health(url, runtime.health.deadline).await,
4385        None => probe_module_health(&spec.module_id, runtime, None).await,
4386    };
4387    match result {
4388        Ok(report) => {
4389            handle_health_report(
4390                spec,
4391                runtime,
4392                registry,
4393                process_liveness,
4394                snapshot,
4395                child,
4396                report,
4397                now_ms,
4398            )
4399            .await;
4400        }
4401        Err(err) => {
4402            if http.is_some() {
4403                let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4404                    state.health.status = SupervisorHealthStatus::Failing;
4405                });
4406            }
4407            handle_health_probe_failure(
4408                spec,
4409                runtime,
4410                registry,
4411                process_liveness,
4412                snapshot,
4413                child,
4414                err,
4415                now_ms,
4416            )
4417            .await;
4418        }
4419    }
4420}
4421
4422pub(crate) struct HttpProbeTarget<'a> {
4423    address: std::net::SocketAddr,
4424    localhost: bool,
4425    authority: &'a str,
4426    path: String,
4427}
4428
4429/// Resolve only literal loopback endpoints, without DNS, redirects, proxies,
4430/// or TLS. A URL cannot turn a local health check into an outbound connection.
4431pub(crate) fn parse_http_probe_url(url: &str) -> Result<HttpProbeTarget<'_>, String> {
4432    if url.bytes().any(|b| b <= b' ' || b == 127) || url.contains('#') {
4433        return Err("must not contain whitespace, controls, or a fragment".into());
4434    }
4435    let rest = url
4436        .strip_prefix("http://")
4437        .ok_or("must use plain http://")?;
4438    let split = rest.find(['/', '?']).unwrap_or(rest.len());
4439    let (authority, suffix) = rest.split_at(split);
4440    let (host, port) = if let Some(rest) = authority.strip_prefix("[::1]") {
4441        ("::1", rest)
4442    } else {
4443        let split = authority.find(':').unwrap_or(authority.len());
4444        authority.split_at(split)
4445    };
4446    let ip: std::net::IpAddr = match host {
4447        "127.0.0.1" | "localhost" => std::net::Ipv4Addr::LOCALHOST.into(),
4448        "::1" => std::net::Ipv6Addr::LOCALHOST.into(),
4449        _ => return Err("host must be 127.0.0.1, [::1], or localhost".into()),
4450    };
4451    let port = if port.is_empty() {
4452        80
4453    } else {
4454        port.strip_prefix(':')
4455            .and_then(|p| p.parse::<u16>().ok())
4456            .filter(|p| *p > 0)
4457            .ok_or("must have a valid nonzero TCP port")?
4458    };
4459    let path = if suffix.is_empty() {
4460        "/".into()
4461    } else if suffix.starts_with('?') {
4462        format!("/{suffix}")
4463    } else {
4464        suffix.into()
4465    };
4466    Ok(HttpProbeTarget {
4467        address: std::net::SocketAddr::new(ip, port),
4468        localhost: host == "localhost",
4469        authority,
4470        path,
4471    })
4472}
4473
4474async fn probe_http_health(
4475    url: &str,
4476    deadline: Duration,
4477) -> Result<HealthReport, HealthProbeError> {
4478    use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader};
4479    let target = parse_http_probe_url(url).map_err(HealthProbeError::misconfigured)?;
4480    // Keep partial diagnostics outside the timed future so cancellation does
4481    // not discard a status line or body bytes already received.
4482    let mut response_status = String::new();
4483    let mut body = Vec::new();
4484    let probe = async {
4485        // Resolve localhost ourselves so a hosts-file override cannot turn
4486        // this into an outbound request, while IPv6-only local servers work.
4487        let connection = match tokio::net::TcpStream::connect(target.address).await {
4488            Err(_) if target.localhost => {
4489                tokio::net::TcpStream::connect((
4490                    std::net::Ipv6Addr::LOCALHOST,
4491                    target.address.port(),
4492                ))
4493                .await
4494            }
4495            result => result,
4496        };
4497        let mut stream = connection.map_err(|error| {
4498            HealthProbeError::no_answer(format!("HTTP connect failed: {error}"))
4499        })?;
4500        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
4501            .map_err(|error| HealthProbeError::no_answer(format!("HTTP write failed: {error}")))?;
4502        let mut reader = BufReader::new(stream);
4503        let mut budget = 16 * 1024;
4504        let status = http_line(&mut reader, &mut budget).await?;
4505        let mut words = status.split_ascii_whitespace();
4506        let version = words.next();
4507        let code = words
4508            .next()
4509            .filter(|word| word.len() == 3)
4510            .and_then(|word| word.parse::<u16>().ok());
4511        if !matches!(version, Some("HTTP/1.1" | "HTTP/1.0"))
4512            || !code.is_some_and(|code| (100..600).contains(&code))
4513        {
4514            return Err(HealthProbeError::bad_answer(format!(
4515                "invalid HTTP status: {status}"
4516            )));
4517        }
4518        let code = code.expect("validated status code");
4519        response_status = status.clone();
4520        let mut length = None;
4521        let mut chunked = false;
4522        loop {
4523            let line = http_line(&mut reader, &mut budget).await?;
4524            if line.is_empty() {
4525                break;
4526            }
4527            if let Some((name, value)) = line.split_once(':') {
4528                if name.eq_ignore_ascii_case("content-length") {
4529                    length = Some(value.trim().parse::<u64>().map_err(|_| {
4530                        HealthProbeError::bad_answer("invalid HTTP Content-Length")
4531                    })?);
4532                } else if name.eq_ignore_ascii_case("transfer-encoding") {
4533                    chunked = value.trim().eq_ignore_ascii_case("chunked");
4534                }
4535            }
4536        }
4537        if chunked {
4538            while body.len() < 200 {
4539                let line = http_line(&mut reader, &mut budget).await?;
4540                let size = u64::from_str_radix(line.split(';').next().unwrap_or("").trim(), 16)
4541                    .map_err(|_| HealthProbeError::bad_answer("invalid HTTP chunk size"))?;
4542                if size == 0 {
4543                    break;
4544                }
4545                let count = size.min((200 - body.len()) as u64) as usize;
4546                let start = body.len();
4547                (&mut reader)
4548                    .take(count as u64)
4549                    .read_to_end(&mut body)
4550                    .await
4551                    .map_err(|error| {
4552                        HealthProbeError::no_answer(format!("HTTP body read failed: {error}"))
4553                    })?;
4554                if body.len() - start != count {
4555                    return Err(HealthProbeError::bad_answer("truncated HTTP chunk"));
4556                }
4557                if size > count as u64 || body.len() == 200 {
4558                    break;
4559                }
4560                if !http_line(&mut reader, &mut budget).await?.is_empty() {
4561                    return Err(HealthProbeError::bad_answer("invalid HTTP chunk delimiter"));
4562                }
4563            }
4564        } else {
4565            reader
4566                .take(length.unwrap_or(200).min(200))
4567                .read_to_end(&mut body)
4568                .await
4569                .map_err(|error| {
4570                    HealthProbeError::no_answer(format!("HTTP body read failed: {error}"))
4571                })?;
4572        }
4573        if (200..300).contains(&code) {
4574            Ok(HealthReport::ok())
4575        } else {
4576            Err(HealthProbeError::bad_answer(
4577                "HTTP health endpoint returned non-2xx",
4578            ))
4579        }
4580    };
4581    let mut result = timeout(deadline, probe).await.unwrap_or_else(|_| {
4582        Err(HealthProbeError::no_answer(format!(
4583            "HTTP probe timed out after {deadline:?}"
4584        )))
4585    });
4586    if let Err(error) = &mut result {
4587        if !response_status.is_empty() {
4588            error.message = format!(
4589                "{}; {response_status}: {}",
4590                error.message,
4591                String::from_utf8_lossy(&body)
4592            );
4593        }
4594    }
4595    result
4596}
4597
4598async fn http_line(
4599    reader: &mut tokio::io::BufReader<tokio::net::TcpStream>,
4600    remaining: &mut usize,
4601) -> Result<String, HealthProbeError> {
4602    use tokio::io::{AsyncBufReadExt, AsyncReadExt};
4603    let mut line = Vec::new();
4604    (&mut *reader)
4605        .take(*remaining as u64)
4606        .read_until(b'\n', &mut line)
4607        .await
4608        .map_err(|error| {
4609            HealthProbeError::no_answer(format!("HTTP header read failed: {error}"))
4610        })?;
4611    *remaining -= line.len();
4612    if !line.ends_with(b"\r\n") {
4613        return Err(HealthProbeError::bad_answer(
4614            "HTTP headers are incomplete or exceed 16 KiB",
4615        ));
4616    }
4617    line.truncate(line.len() - 2);
4618    String::from_utf8(line).map_err(|_| HealthProbeError::bad_answer("HTTP header is not UTF-8"))
4619}
4620
4621async fn probe_module_health(
4622    module_id: &str,
4623    runtime: &SupervisorRuntimeConfig,
4624    drain_deadline: Option<Instant>,
4625) -> Result<HealthReport, HealthProbeError> {
4626    let Some(forwarding) = runtime.forwarding.as_ref() else {
4627        return Err(HealthProbeError::misconfigured(
4628            "supervisor was not configured with a forwarding table",
4629        ));
4630    };
4631    let probe_started_at = Instant::now();
4632    let mut deadline = probe_started_at + runtime.health.deadline;
4633    if let Some(drain_deadline) = drain_deadline {
4634        deadline = deadline.min(drain_deadline);
4635    }
4636    let pending = if drain_deadline.is_some() {
4637        forwarding.begin_drain_health_probe_rpc_for(
4638            module_id,
4639            MODULE_CONTROL_OP_HEALTH_CHECK,
4640            probe_started_at,
4641            deadline,
4642        )
4643    } else {
4644        forwarding.begin_health_probe_rpc_for(
4645            module_id,
4646            MODULE_CONTROL_OP_HEALTH_CHECK,
4647            probe_started_at,
4648            deadline,
4649        )
4650    }
4651    .map_err(|err| {
4652        // The endpoint is not registered, so there is no live control lane to
4653        // ask. That is the module being absent, not slow.
4654        HealthProbeError::lane_dead(format!("failed to begin health.check RPC: {err}"))
4655    })?;
4656    await_health_probe(forwarding, pending, deadline, runtime.health.deadline).await
4657}
4658
4659/// [`probe_module_health`] for one endpoint rather than the id's active one.
4660///
4661/// A swap probes two processes that no by-id lookup reaches: its candidate
4662/// before cutover, and its superseded incumbent (for busy gauges) while the
4663/// incumbent drains. `deadline_cap` bounds the probe the way a drain deadline
4664/// bounds the by-id drain probe.
4665async fn probe_endpoint_health(
4666    endpoint: crate::ModuleEndpointId,
4667    runtime: &SupervisorRuntimeConfig,
4668    deadline_cap: Option<Instant>,
4669) -> Result<HealthReport, HealthProbeError> {
4670    let Some(forwarding) = runtime.forwarding.as_ref() else {
4671        return Err(HealthProbeError::misconfigured(
4672            "supervisor was not configured with a forwarding table",
4673        ));
4674    };
4675    let probe_started_at = Instant::now();
4676    let mut deadline = probe_started_at + runtime.health.deadline;
4677    if let Some(cap) = deadline_cap {
4678        deadline = deadline.min(cap);
4679    }
4680    let pending = forwarding
4681        .begin_endpoint_health_probe_rpc_for(
4682            endpoint,
4683            MODULE_CONTROL_OP_HEALTH_CHECK,
4684            probe_started_at,
4685            deadline,
4686        )
4687        .map_err(|err| {
4688            HealthProbeError::lane_dead(format!("failed to begin health.check RPC: {err}"))
4689        })?;
4690    await_health_probe(forwarding, pending, deadline, runtime.health.deadline).await
4691}
4692
4693/// Send a begun health probe and classify its answer.
4694async fn await_health_probe(
4695    forwarding: &ForwardingTable,
4696    pending: PendingModuleControlRpc,
4697    deadline: Instant,
4698    probe_budget: Duration,
4699) -> Result<HealthReport, HealthProbeError> {
4700    let PendingModuleControlRpc {
4701        endpoint,
4702        module_sink,
4703        negotiated_ver,
4704        corr,
4705        receiver,
4706    } = pending;
4707    let body = serde_json::to_vec(&ModuleControlRequest::HealthCheck {}).map_err(|err| {
4708        HealthProbeError::misconfigured(format!("failed to encode health.check: {err}"))
4709    })?;
4710    let frame = Frame::build_with_version(
4711        negotiated_ver,
4712        FrameType::Request,
4713        control_flags(),
4714        0,
4715        0,
4716        corr,
4717        body,
4718    )
4719    .map_err(|err| {
4720        HealthProbeError::misconfigured(format!("failed to build health.check frame: {err}"))
4721    })?;
4722
4723    // The enqueue itself must be bounded by the probe deadline: FrameSink.send
4724    // blocks waiting for capacity when the module's egress queue is full, and an
4725    // unbounded await here freezes the whole supervision actor (it stops polling
4726    // Child::wait and supervisor commands), making the module unrecoverable
4727    // in-band. On timeout the probe fails like any transport failure.
4728    match timeout_at(deadline, module_sink.send(frame)).await {
4729        Ok(Ok(())) => {}
4730        Ok(Err(err)) => {
4731            let _ = forwarding.cancel_module_control_rpc(endpoint, corr);
4732            // A closed sink means the module's egress channel is gone -- the
4733            // receiving half is dropped when its connection tears down. Proof.
4734            return Err(HealthProbeError::lane_dead(format!(
4735                "failed to send health.check: {err}"
4736            )));
4737        }
4738        Err(_elapsed) => {
4739            let _ = forwarding.cancel_module_control_rpc(endpoint, corr);
4740            // A full egress queue means the module is not draining its socket, which
4741            // is consistent with a wedged module AND with one whose reader is merely
4742            // starved. Silence, not proof.
4743            return Err(HealthProbeError::no_answer(
4744                "health.check send timed out before enqueue (module egress full)",
4745            ));
4746        }
4747    }
4748
4749    match timeout_at(deadline, receiver).await {
4750        // Each arm records WHAT WAS OBSERVED. Four of them are the module
4751        // demonstrably answering -- rejected, non-health, malformed, wrong op --
4752        // and those prove it is alive even though the probe failed.
4753        Ok(Ok(ModuleControlRpcOutcome::Response(response))) => {
4754            response.health_report().ok_or_else(|| {
4755                HealthProbeError::bad_answer("health.check RPC returned a non-health response")
4756            })
4757        }
4758        Ok(Ok(ModuleControlRpcOutcome::Rejected(body))) => Err(HealthProbeError::bad_answer(
4759            format!("health.check rejected: {}", body.message),
4760        )),
4761        Ok(Ok(ModuleControlRpcOutcome::ModuleGone(message))) => {
4762            Err(HealthProbeError::lane_dead(message))
4763        }
4764        Ok(Ok(ModuleControlRpcOutcome::MalformedResponse(message))) => {
4765            Err(HealthProbeError::bad_answer(message))
4766        }
4767        Ok(Ok(ModuleControlRpcOutcome::UnexpectedOp { expected, actual })) => {
4768            Err(HealthProbeError::bad_answer(format!(
4769                "expected module-control op '{expected}', got '{actual}'"
4770            )))
4771        }
4772        // A reply that crosses the deadline before this waiter observes it is
4773        // still proof of life. The forwarding path records its end-to-end latency
4774        // before delivering this classification.
4775        Ok(Ok(ModuleControlRpcOutcome::DeadlineElapsed)) => Err(HealthProbeError::bad_answer(
4776            "module answered health.check after its daemon deadline",
4777        )),
4778        Ok(Err(_)) => Err(HealthProbeError::misconfigured(
4779            "health.check waiter was canceled before the module responded",
4780        )),
4781        Err(_) => {
4782            let _ = forwarding.tombstone_health_probe_rpc(endpoint, corr);
4783            Err(HealthProbeError::no_answer(format!(
4784                "module did not answer health.check within {probe_budget:?}"
4785            )))
4786        }
4787    }
4788}
4789
4790#[allow(clippy::too_many_arguments)]
4791async fn handle_health_report(
4792    spec: &ModuleSpec,
4793    runtime: &SupervisorRuntimeConfig,
4794    registry: &Registry,
4795    process_liveness: &SupervisorProcessLiveness,
4796    snapshot: &SharedSnapshot,
4797    child: &mut Option<SupervisedChild>,
4798    report: HealthReport,
4799    now_ms: u64,
4800) {
4801    let status = supervisor_health_status(report.status);
4802    let detail = report.detail.clone();
4803    let metrics = truncate_health_metrics(report.metrics);
4804    let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4805        state.health.status = status;
4806        state.health.last_probe_ms = Some(now_ms);
4807        state.health.detail = detail.clone();
4808        state.health.metrics = metrics.clone();
4809        state.health.consecutive_failures = 0;
4810    });
4811
4812    let action = match report.status {
4813        HealthStatus::Ok => return,
4814        HealthStatus::Degraded => runtime.health.on_degraded,
4815        HealthStatus::Failing => runtime.health.on_failing,
4816    };
4817    apply_l3_health_action(
4818        spec,
4819        runtime,
4820        registry,
4821        process_liveness,
4822        snapshot,
4823        child,
4824        status,
4825        detail.as_deref(),
4826        action,
4827        now_ms,
4828    )
4829    .await;
4830}
4831
4832#[allow(clippy::too_many_arguments)]
4833async fn handle_health_probe_failure(
4834    spec: &ModuleSpec,
4835    runtime: &SupervisorRuntimeConfig,
4836    registry: &Registry,
4837    process_liveness: &SupervisorProcessLiveness,
4838    snapshot: &SharedSnapshot,
4839    child: &mut Option<SupervisedChild>,
4840    err: HealthProbeError,
4841    now_ms: u64,
4842) {
4843    let threshold = runtime.health.failure_threshold.max(1);
4844    let mut failures = 0;
4845    // Carry the evidence class into the operator-visible detail. Without it,
4846    // "module did not answer within 5s" and "the control lane is gone" are two
4847    // prose strings in the same field, and the reader has to know the codebase to
4848    // tell which one is proof of anything.
4849    let detail = format!("[{}] {err}", err.label());
4850    let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4851        // A failed wire probe invalidates the last report, even before the
4852        // restart threshold. HTTP probes already mark failures as Failing.
4853        if state.spawned_protocol.unwrap_or(spec.protocol) == ModuleProtocol::Subc {
4854            state.health.status = SupervisorHealthStatus::Unknown;
4855        }
4856        state.health.last_probe_ms = Some(now_ms);
4857        state.health.consecutive_failures = state.health.consecutive_failures.saturating_add(1);
4858        state.health.detail = Some(detail.clone());
4859        state.health.metrics = None;
4860        failures = state.health.consecutive_failures;
4861    });
4862
4863    if failures < threshold {
4864        warn!(
4865            module_id = %spec.module_id,
4866            consecutive_failures = failures,
4867            threshold,
4868            evidence = err.label(),
4869            detail = %detail,
4870            "health.check probe failed"
4871        );
4872        return;
4873    }
4874
4875    let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4876        state.state = ModuleState::Unresponsive;
4877        state.health.status = SupervisorHealthStatus::Unresponsive;
4878    });
4879    // The evidence class is logged at the kill site because this is the line an
4880    // operator reads after an unexplained restart. A streak of `no-answer` under
4881    // machine load is the known false-positive shape; a `lane-dead` is not.
4882    if runtime.health.critical {
4883        error!(
4884            module_id = %spec.module_id,
4885            status = "unresponsive",
4886            evidence = err.label(),
4887            detail = %detail,
4888            "critical module health alert"
4889        );
4890    } else {
4891        warn!(
4892            module_id = %spec.module_id,
4893            status = "unresponsive",
4894            evidence = err.label(),
4895            detail = %detail,
4896            "module health threshold breached"
4897        );
4898    }
4899    if let Err(err) = health_restart_child(
4900        spec,
4901        runtime,
4902        registry,
4903        process_liveness,
4904        snapshot,
4905        child,
4906        SupervisorHealthStatus::Unresponsive,
4907        Some(&detail),
4908        now_ms,
4909    )
4910    .await
4911    {
4912        error!(module_id = %spec.module_id, error = %err, "health-triggered restart failed");
4913    }
4914}
4915
4916#[allow(clippy::too_many_arguments)]
4917async fn apply_l3_health_action(
4918    spec: &ModuleSpec,
4919    runtime: &SupervisorRuntimeConfig,
4920    registry: &Registry,
4921    process_liveness: &SupervisorProcessLiveness,
4922    snapshot: &SharedSnapshot,
4923    child: &mut Option<SupervisedChild>,
4924    status: SupervisorHealthStatus,
4925    detail: Option<&str>,
4926    action: HealthAction,
4927    now_ms: u64,
4928) {
4929    record_health_action(snapshot, &spec.module_id, action.to_string(), now_ms);
4930    match action {
4931        HealthAction::Report => {
4932            info!(
4933                module_id = %spec.module_id,
4934                status = ?status,
4935                detail,
4936                "module reported non-ok health"
4937            );
4938        }
4939        HealthAction::Alert => {
4940            error!(
4941                module_id = %spec.module_id,
4942                status = ?status,
4943                detail,
4944                "module health alert"
4945            );
4946        }
4947        HealthAction::Restart => {
4948            if let Err(err) = health_restart_child(
4949                spec,
4950                runtime,
4951                registry,
4952                process_liveness,
4953                snapshot,
4954                child,
4955                status,
4956                detail,
4957                now_ms,
4958            )
4959            .await
4960            {
4961                error!(module_id = %spec.module_id, error = %err, "health-triggered restart failed");
4962            }
4963        }
4964    }
4965}
4966
4967#[allow(clippy::too_many_arguments)]
4968async fn health_restart_child(
4969    spec: &ModuleSpec,
4970    runtime: &SupervisorRuntimeConfig,
4971    registry: &Registry,
4972    process_liveness: &SupervisorProcessLiveness,
4973    snapshot: &SharedSnapshot,
4974    child: &mut Option<SupervisedChild>,
4975    status: SupervisorHealthStatus,
4976    detail: Option<&str>,
4977    now_ms: u64,
4978) -> Result<(), SuperviseError> {
4979    let (enabled, schedule) = {
4980        let mut state = lock_snapshot(snapshot)?;
4981        let enabled = state.enabled;
4982        let schedule = if enabled {
4983            state.next_crash_restart(&runtime.restart_policy, Instant::now())
4984        } else {
4985            None
4986        };
4987        (enabled, schedule)
4988    };
4989
4990    if !enabled {
4991        return Err(SuperviseError::Disabled {
4992            module_id: spec.module_id.clone(),
4993        });
4994    }
4995
4996    if schedule.is_none() {
4997        record_health_action(snapshot, &spec.module_id, "failed".to_string(), now_ms);
4998        error!(
4999            module_id = %spec.module_id,
5000            status = ?status,
5001            detail,
5002            max_restarts = runtime.restart_policy.max_restarts,
5003            window_secs = runtime.restart_policy.window.as_secs(),
5004            reason = %runtime.restart_policy.budget_exhausted_detail(),
5005            "health restart budget exhausted; marking module failed"
5006        );
5007        let stop_notice = begin_forwarding_drain_if_configured(
5008            spec,
5009            runtime,
5010            registry,
5011            snapshot,
5012            Some(true),
5013            RouteCloseReason::Disable,
5014        )
5015        .await?;
5016        update_snapshot(snapshot, Some(&spec.module_id), |state| {
5017            state.drain_disposition_detail = Some(runtime.restart_policy.budget_exhausted_detail());
5018        })?;
5019        drain_optional_child(
5020            &spec.module_id,
5021            spec.protocol,
5022            stop_notice,
5023            registry,
5024            runtime.forwarding.as_deref(),
5025            snapshot,
5026            &runtime.terminal_ring,
5027            &runtime.spawn_events,
5028            child,
5029            runtime.drain_timeout,
5030            ModuleState::Failed,
5031            Some(true),
5032        )
5033        .await?;
5034        process_liveness.untrack_if_current(&spec.module_id, snapshot);
5035        return Ok(());
5036    }
5037
5038    let schedule = schedule.expect("a health restart must have a crash-restart schedule");
5039    let mut restart_count = 0;
5040    update_snapshot(snapshot, Some(&spec.module_id), |state| {
5041        restart_count = state.crash_restarts.len();
5042        state.state = ModuleState::Unresponsive;
5043        state.health.status = status;
5044        state.health.last_action = Some(HealthAction::Restart.to_string());
5045        state.health.last_action_ms = Some(now_ms);
5046    })?;
5047    warn!(
5048        module_id = %spec.module_id,
5049        status = ?status,
5050        detail,
5051        restart_count,
5052        restart_in_window = schedule.restart_in_window,
5053        delay_ms = schedule.delay.as_millis() as u64,
5054        "health-triggered module restart"
5055    );
5056
5057    let stop_notice = begin_forwarding_drain_if_configured(
5058        spec,
5059        runtime,
5060        registry,
5061        snapshot,
5062        Some(true),
5063        RouteCloseReason::Restart,
5064    )
5065    .await?;
5066    drain_optional_child(
5067        &spec.module_id,
5068        spec.protocol,
5069        stop_notice,
5070        registry,
5071        runtime.forwarding.as_deref(),
5072        snapshot,
5073        &runtime.terminal_ring,
5074        &runtime.spawn_events,
5075        child,
5076        runtime.drain_timeout,
5077        ModuleState::Restarting,
5078        Some(true),
5079    )
5080    .await?;
5081    schedule_respawn(
5082        runtime,
5083        snapshot,
5084        &spec.module_id,
5085        schedule.delay,
5086        RespawnKind::Spawn,
5087    )
5088}
5089
5090fn cancel_deferred_reload(runtime: &SupervisorRuntimeConfig, module_id: &str, reason: &str) {
5091    if let Some(reply) = runtime
5092        .deferred_reload_reply
5093        .lock()
5094        .unwrap_or_else(|p| p.into_inner())
5095        .take()
5096    {
5097        let _ = reply.send(Err(SuperviseError::ReloadFailed {
5098            module_id: module_id.to_string(),
5099            reason: reason.to_string(),
5100        }));
5101    }
5102}
5103
5104fn schedule_respawn(
5105    runtime: &SupervisorRuntimeConfig,
5106    snapshot: &SharedSnapshot,
5107    module_id: &str,
5108    delay: Duration,
5109    kind: RespawnKind,
5110) -> Result<(), SuperviseError> {
5111    cancel_deferred_reload(runtime, module_id, "respawn superseded by another restart");
5112    update_snapshot(snapshot, Some(module_id), |state| {
5113        state.respawn_pending = true
5114    })?;
5115    *runtime
5116        .scheduled_respawn
5117        .lock()
5118        .unwrap_or_else(|p| p.into_inner()) = Some(PendingRespawn {
5119        deadline: Instant::now() + delay,
5120        kind,
5121    });
5122    Ok(())
5123}
5124
5125fn record_health_action(snapshot: &SharedSnapshot, module_id: &str, action: String, now_ms: u64) {
5126    let _ = update_snapshot(snapshot, Some(module_id), |state| {
5127        state.health.last_action = Some(action);
5128        state.health.last_action_ms = Some(now_ms);
5129    });
5130}
5131
5132fn supervisor_health_status(status: HealthStatus) -> SupervisorHealthStatus {
5133    match status {
5134        HealthStatus::Ok => SupervisorHealthStatus::Ok,
5135        HealthStatus::Degraded => SupervisorHealthStatus::Degraded,
5136        HealthStatus::Failing => SupervisorHealthStatus::Failing,
5137    }
5138}
5139
5140/// Caps the metrics blob stored in the cached supervisor snapshot, which is
5141/// returned to every `supervisor.list` and `supervisor.health` caller.
5142///
5143/// This cap is deliberately NOT applied on the one-shot `supervisor.health_probe`
5144/// path: that request exists to return a module's complete metrics object, and
5145/// `ck health <module-id>` documents it as the way to see what the cached view
5146/// truncates. The asymmetry is the feature.
5147///
5148/// So a new caller must decide which side it is on rather than assume the cap is
5149/// universal. Reaching for it on a fresh-probe path would silently reintroduce
5150/// the truncation that path exists to avoid.
5151fn truncate_health_metrics(metrics: Option<Value>) -> Option<Value> {
5152    let metrics = metrics?;
5153    match serde_json::to_vec(&metrics) {
5154        Ok(encoded) if encoded.len() > MAX_HEALTH_METRICS_BYTES => Some(serde_json::json!({
5155            "truncated": true,
5156            "original_bytes": encoded.len(),
5157        })),
5158        Ok(_) | Err(_) => Some(metrics),
5159    }
5160}
5161
5162/// Spread health probes so a fleet-wide restart does not converge them.
5163///
5164/// The delay is derived from the module id and probe index rather than a random
5165/// source, so it is deterministic per module: a module keeps its own offset
5166/// across daemon restarts instead of re-rolling into a collision.
5167fn jittered_health_delay(module_id: &str, probe_index: u64, cadence: Duration) -> Duration {
5168    if cadence.is_zero() {
5169        return Duration::ZERO;
5170    }
5171    let cadence_ms = cadence.as_millis() as u64;
5172    // This early return is REDUNDANT, deliberately, and a mutation run will show
5173    // it surviving removal. Recording why here so the next person to notice does
5174    // not have to re-derive it:
5175    //
5176    // - It is unreachable in practice. `positive_millis` in daemon_config rejects
5177    //   a zero cadence and builds the Duration from whole milliseconds, so a
5178    //   sub-millisecond cadence cannot come from config.
5179    // - Even if reached it changes no answer. The `.max(1)` below makes the span
5180    //   1, and `hash % 1` is 0, so the fall-through returns `cadence` unchanged
5181    //   -- exactly what this returns.
5182    //
5183    // Kept as a guard against a future widening of the config parser (accepting
5184    // microseconds, say), which would make the sub-millisecond case reachable.
5185    // The `.max(1)` is the load-bearing half TODAY: remove it and the modulo
5186    // divides by zero. Remove this and nothing changes.
5187    if cadence_ms == 0 {
5188        return cadence;
5189    }
5190    // Note that this never returns less than one cadence, including for the FIRST
5191    // probe. So a freshly registered module reports health `unknown` for a full
5192    // cadence plus jitter -- 30-33s at the default -- no matter how quickly it is
5193    // ready to answer.
5194    //
5195    // That is a property of the supervisor's schedule, not of any module: an
5196    // operator watching a restart sees `unknown` and cannot tell it from a module
5197    // that is slow to warm. Measured on two unrelated modules, both flipping to
5198    // `ok` between 22s and 32s after restart.
5199    //
5200    // Left as-is because spreading the first probe is what keeps a fleet-wide
5201    // restart from firing fourteen simultaneous probes into a cold machine. The
5202    // alternative -- probe at t+0 and jitter only from the second onward -- trades
5203    // that thundering herd for a faster first reading.
5204    let jitter_span = (cadence_ms / 10).max(1);
5205    let hash = module_id.as_bytes().iter().fold(
5206        probe_index.wrapping_mul(0x9E37_79B9_7F4A_7C15),
5207        |acc, byte| {
5208            acc.wrapping_mul(1099511628211)
5209                .wrapping_add(u64::from(*byte))
5210        },
5211    );
5212    cadence + Duration::from_millis(hash % jitter_span)
5213}
5214
5215#[cfg(test)]
5216mod tests {
5217    use super::*;
5218
5219    #[test]
5220    fn readding_a_module_clears_its_rescan_removal_tombstone() {
5221        let handle = SupervisorHandle::new();
5222        let module_id = "readded-tombstone";
5223        handle.record_rescan_removal(module_id);
5224        assert!(handle.removal_tombstone_age_ms(module_id).is_some());
5225
5226        handle.apply_identity_configuration(&ModuleSpec {
5227            module_id: module_id.to_string(),
5228            program: PathBuf::from("/test/module"),
5229            args: Vec::new(),
5230            env: Vec::new(),
5231            reserved: false,
5232            reserved_prefixes: Vec::new(),
5233            protocol: ModuleProtocol::Subc,
5234            overlap: Default::default(),
5235        });
5236
5237        assert!(
5238            handle.removal_tombstone_age_ms(module_id).is_none(),
5239            "a re-added module must not retain a stale removal tombstone"
5240        );
5241    }
5242
5243    /// What one module's owner looked like from the control plane at the
5244    /// instant after its first process was spawned.
5245    #[derive(Debug, PartialEq, Eq)]
5246    struct OwnerInSpawnWindow {
5247        module_id: String,
5248        configured: bool,
5249        on_roster: bool,
5250        admission_refusal: Option<&'static str>,
5251    }
5252
5253    /// A supervised module's process can connect, register, sync its scopes
5254    /// and describe them as soon as it is spawned, which is BEFORE the
5255    /// supervisor puts the module on the roster. In that window the owner must
5256    /// already read as configured, so a scoped `route.open` against it is
5257    /// refused as retryable `scope_not_synced` and not as terminal
5258    /// `scope_not_live` ("will never sync").
5259    ///
5260    /// The hook runs in exactly that window on every path that takes on a new
5261    /// module, so no race with a real child is needed: `on_roster: false`
5262    /// proves each observation was taken before the roster insert.
5263    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5264    async fn a_new_module_is_configured_before_its_first_process_can_register() {
5265        use crate::scopes::ScopeTable;
5266        use subc_protocol::{error_codes, scope::ScopeSelector, Principal};
5267
5268        let dir = cortexkit_test_support::ScratchDir::new("configured-before-spawn");
5269        let stub = |module_id: &str, program: PathBuf| ModuleSpec {
5270            module_id: module_id.to_string(),
5271            program,
5272            args: Vec::new(),
5273            env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
5274                .into_iter()
5275                .map(|key| (key.to_string(), dir.path().display().to_string()))
5276                .chain([("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string())])
5277                .collect(),
5278            reserved: false,
5279            reserved_prefixes: Vec::new(),
5280            protocol: ModuleProtocol::Subc,
5281            overlap: Default::default(),
5282        };
5283        let live = super::terminal_history_tests::fake_aft_stub_path();
5284        let missing = dir.path().join("definitely-missing-module");
5285
5286        let handle = SupervisorHandle::new();
5287        let mut supervisor =
5288            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5289                .with_handle(handle.clone());
5290        let observed = Arc::new(Mutex::new(Vec::<OwnerInSpawnWindow>::new()));
5291        let hook_handle = handle.clone();
5292        let hook_observed = Arc::clone(&observed);
5293        supervisor.test_after_first_spawn = AfterFirstSpawnHook(Some(Arc::new(move |module_id| {
5294            // Exactly what the control plane computes for a scoped route.open
5295            // naming this module as the owner of a scope it has not synced.
5296            let configured = hook_handle.is_configured(module_id);
5297            let selector = ScopeSelector {
5298                owner: Principal::Reserved {
5299                    module_id: module_id.to_string(),
5300                },
5301                scope_ref: "s".to_string(),
5302                scope_epoch: Some(1),
5303            };
5304            let carrier = Principal::Reserved {
5305                module_id: "carrier".to_string(),
5306            };
5307            let admission_refusal = match ScopeTable::new(Vec::<String>::new())
5308                .admit(&carrier, module_id, &selector, configured)
5309            {
5310                Ok(_) => None,
5311                Err(refusal) => Some(refusal.code),
5312            };
5313            hook_observed.lock().unwrap().push(OwnerInSpawnWindow {
5314                module_id: module_id.to_string(),
5315                configured,
5316                on_roster: hook_handle.get(module_id).is_some(),
5317                admission_refusal,
5318            });
5319        })));
5320
5321        let plain = supervisor.spawn(stub("plain", live.clone())).unwrap();
5322        let configured = supervisor
5323            .supervise_configured(stub("configured", live.clone()), true)
5324            .unwrap();
5325        let with_health = supervisor
5326            .supervise_configured_with_health(
5327                stub("with-health", live.clone()),
5328                true,
5329                HealthConfig::default(),
5330                None,
5331                RestartPolicy::default(),
5332            )
5333            .unwrap();
5334        // The failed-spawn path still puts the module on the roster (as
5335        // failed), so it is configured throughout.
5336        let failed = supervisor
5337            .supervise_configured_with_health(
5338                stub("failed-spawn", missing.clone()),
5339                true,
5340                HealthConfig::default(),
5341                None,
5342                RestartPolicy::default(),
5343            )
5344            .unwrap();
5345        // A failed plain `spawn` puts nothing on the roster, so its mark is
5346        // taken back once the spawn has failed.
5347        assert!(supervisor.spawn(stub("spawn-error", missing)).is_err());
5348
5349        let expected = [
5350            "plain",
5351            "configured",
5352            "with-health",
5353            "failed-spawn",
5354            "spawn-error",
5355        ]
5356        .into_iter()
5357        .map(|module_id| OwnerInSpawnWindow {
5358            module_id: module_id.to_string(),
5359            configured: true,
5360            on_roster: false,
5361            admission_refusal: Some(error_codes::SCOPE_NOT_SYNCED),
5362        })
5363        .collect::<Vec<_>>();
5364        assert_eq!(*observed.lock().unwrap(), expected);
5365
5366        for module_id in ["plain", "configured", "with-health", "failed-spawn"] {
5367            assert!(
5368                handle.get(module_id).is_some(),
5369                "{module_id} is on the roster"
5370            );
5371            assert!(
5372                handle.is_configured(module_id),
5373                "{module_id} stays configured"
5374            );
5375        }
5376        assert!(handle.get("spawn-error").is_none());
5377        assert!(
5378            !handle.is_configured("spawn-error"),
5379            "a plain spawn that failed must not leave its module marked configured"
5380        );
5381
5382        // Leaving the roster clears the mark with it.
5383        handle.retire("failed-spawn");
5384        assert!(!handle.is_configured("failed-spawn"));
5385
5386        for module in [plain, configured, with_health] {
5387            module.stop().await.unwrap();
5388        }
5389        drop(failed);
5390    }
5391
5392    fn stale_process_snapshot(state: ModuleState, enabled: bool) -> SharedSnapshot {
5393        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(state, enabled)));
5394        update_snapshot(&snapshot, Some("stale-process-facts"), |snapshot| {
5395            snapshot.process_alive = true;
5396            snapshot.pid = Some(41);
5397            snapshot.spawned_at_ms = Some(42);
5398            snapshot.spawned_from = Some(PathBuf::from("/spawned/module"));
5399            snapshot.spawned_file_identity = Some(SpawnedFileIdentity {
5400                device: 43,
5401                inode: 44,
5402                #[cfg(windows)]
5403                windows_image: None,
5404            });
5405        })
5406        .unwrap();
5407        snapshot
5408    }
5409
5410    fn assert_snapshot_process_facts_cleared(snapshot: &SharedSnapshot) {
5411        let snapshot = lock_snapshot(snapshot).unwrap();
5412        assert!(!snapshot.process_alive);
5413        assert_eq!(snapshot.pid, None);
5414        assert_eq!(snapshot.spawned_at_ms, None);
5415        assert_eq!(snapshot.spawned_from, None);
5416        assert_eq!(snapshot.spawned_file_identity, None);
5417    }
5418
5419    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5420    async fn failed_enable_spawn_clears_preexisting_current_process_facts() {
5421        let supervisor =
5422            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5423        let mut runtime = supervisor.runtime_config();
5424        runtime.test_seed_stale_facts_before_enable_spawn = true;
5425        let snapshot = stale_process_snapshot(ModuleState::Disabled, false);
5426        let mut child = None;
5427        let spec = ModuleSpec {
5428            module_id: "failed-enable-clears-facts".to_string(),
5429            program: PathBuf::from("/definitely/missing/failed-enable-module"),
5430            args: Vec::new(),
5431            env: Vec::new(),
5432            reserved: false,
5433            reserved_prefixes: Vec::new(),
5434            protocol: ModuleProtocol::Subc,
5435            overlap: Default::default(),
5436        };
5437
5438        let result = set_child_enabled(
5439            &spec,
5440            &runtime,
5441            &supervisor.registry,
5442            &supervisor.process_liveness,
5443            &snapshot,
5444            &mut child,
5445            true,
5446        )
5447        .await;
5448
5449        assert!(matches!(result, Err(SuperviseError::Spawn { .. })));
5450        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
5451        assert_snapshot_process_facts_cleared(&snapshot);
5452    }
5453
5454    #[tokio::test]
5455    async fn start_revives_stranded_restarting_but_not_pending_backoff() {
5456        let supervisor =
5457            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5458        let runtime = supervisor.runtime_config();
5459        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(
5460            ModuleState::Restarting,
5461            true,
5462        )));
5463        let spec = ModuleSpec {
5464            module_id: "start-stranded-restarting".to_string(),
5465            program: super::terminal_history_tests::fake_aft_stub_path(),
5466            args: Vec::new(),
5467            env: vec![("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string())],
5468            reserved: false,
5469            reserved_prefixes: Vec::new(),
5470            protocol: ModuleProtocol::None,
5471            overlap: Default::default(),
5472        };
5473        let mut child = None;
5474        lock_snapshot(&snapshot).unwrap().respawn_pending = true;
5475        assert!(!super::set_child_enabled(
5476            &spec,
5477            &runtime,
5478            &Registry::default(),
5479            &supervisor.process_liveness,
5480            &snapshot,
5481            &mut child,
5482            true
5483        )
5484        .await
5485        .unwrap());
5486        assert!(child.is_none());
5487        lock_snapshot(&snapshot).unwrap().respawn_pending = false;
5488        assert!(super::set_child_enabled(
5489            &spec,
5490            &runtime,
5491            &Registry::default(),
5492            &supervisor.process_liveness,
5493            &snapshot,
5494            &mut child,
5495            true
5496        )
5497        .await
5498        .unwrap());
5499        assert_eq!(
5500            lock_snapshot(&snapshot).unwrap().state,
5501            ModuleState::Running
5502        );
5503        let mut child = child.unwrap();
5504        child.start_kill().unwrap();
5505        child.wait().await.unwrap();
5506    }
5507
5508    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5509    async fn failed_reload_spawn_clears_current_process_facts() {
5510        let supervisor =
5511            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5512        let mut runtime = supervisor.runtime_config();
5513        runtime.restart_policy = RestartPolicy::new(0, Duration::ZERO);
5514        let snapshot = stale_process_snapshot(ModuleState::Running, true);
5515        let mut child = None;
5516        let spec = ModuleSpec {
5517            module_id: "failed-reload-clears-facts".to_string(),
5518            program: PathBuf::from("/unused/failed-reload-module"),
5519            args: Vec::new(),
5520            env: Vec::new(),
5521            reserved: false,
5522            reserved_prefixes: Vec::new(),
5523            protocol: ModuleProtocol::Subc,
5524            overlap: Default::default(),
5525        };
5526
5527        let result = handle_reload_spawn_failure(
5528            &spec,
5529            &runtime,
5530            &supervisor.process_liveness,
5531            &snapshot,
5532            &mut child,
5533            "forced reload spawn failure".to_string(),
5534        )
5535        .await;
5536
5537        assert!(matches!(result, Err(SuperviseError::ReloadFailed { .. })));
5538        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
5539        assert_snapshot_process_facts_cleared(&snapshot);
5540    }
5541
5542    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5543    async fn dropping_a_module_with_an_active_monitor_clears_current_process_facts() {
5544        let supervisor =
5545            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5546        let snapshot = stale_process_snapshot(ModuleState::Running, true);
5547        let module = supervisor.supervised_module(
5548            ModuleSpec {
5549                module_id: "drop-clears-facts".to_string(),
5550                program: PathBuf::from("/unused/drop-module"),
5551                args: Vec::new(),
5552                env: Vec::new(),
5553                reserved: false,
5554                reserved_prefixes: Vec::new(),
5555                protocol: ModuleProtocol::Subc,
5556                overlap: Default::default(),
5557            },
5558            supervisor.runtime_config(),
5559            Arc::clone(&snapshot),
5560            None,
5561        );
5562        assert!(!module
5563            .inner
5564            .monitor
5565            .lock()
5566            .unwrap()
5567            .as_ref()
5568            .unwrap()
5569            .is_finished());
5570
5571        drop(module);
5572
5573        assert_eq!(
5574            lock_snapshot(&snapshot).unwrap().state,
5575            ModuleState::Stopped
5576        );
5577        assert_snapshot_process_facts_cleared(&snapshot);
5578    }
5579
5580    #[cfg(unix)]
5581    #[tokio::test]
5582    async fn rescan_preserves_running_protocol_until_respawn() {
5583        let dir = cortexkit_test_support::ScratchDir::new("rescan-protocol");
5584        let initial = ModuleSpec {
5585            module_id: "rescan-protocol".into(),
5586            program: PathBuf::from("/bin/sleep"),
5587            args: vec!["60".into()],
5588            env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
5589                .into_iter()
5590                .map(|key| (key.into(), dir.path().to_string_lossy().into_owned()))
5591                .collect(),
5592            reserved: false,
5593            reserved_prefixes: vec![],
5594            protocol: ModuleProtocol::None,
5595            overlap: Default::default(),
5596        };
5597        let supervisor =
5598            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5599        let module = supervisor.spawn(initial.clone()).unwrap();
5600        assert!(module.status().unwrap().live);
5601        let mut next = initial;
5602        next.protocol = ModuleProtocol::Subc;
5603        module
5604            .update_configuration(next.clone(), HealthConfig::default(), None)
5605            .await
5606            .unwrap();
5607        assert!(
5608            module.status().unwrap().live,
5609            "rescan must not require HELLO from the old non-wire process"
5610        );
5611        let runtime = supervisor.runtime_config();
5612        let action = on_child_exit(
5613            &next,
5614            RestartPolicy::default(),
5615            &supervisor.registry,
5616            &module.inner.snapshot,
5617            &runtime.terminal_ring,
5618            &runtime.spawn_events,
5619            &runtime.child_roster,
5620            ExitReport {
5621                kind: ExitKind::Clean,
5622                code: Some(0),
5623                signal: None,
5624                at_ms: unix_ms_now(),
5625            },
5626        )
5627        .await;
5628        assert!(
5629            matches!(action, NextAction::Restart { .. }),
5630            "the old non-wire process's clean exit must restart"
5631        );
5632        module.drain().await.unwrap();
5633    }
5634
5635    #[cfg(unix)]
5636    #[tokio::test]
5637    async fn orphan_identity_matches_path_names_and_shebang_interpreters() {
5638        use std::os::unix::fs::PermissionsExt;
5639        let dir = cortexkit_test_support::ScratchDir::new("spawn-image-identity");
5640        let script = dir.join("module.sh");
5641        std::fs::write(&script, "#!/bin/sh\nwhile :; do sleep 0.1; done\n").unwrap();
5642        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700)).unwrap();
5643        let record_path = dir.join("live-children.json");
5644        let supervisor =
5645            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5646                .with_live_children_record(&record_path);
5647        for (program, args) in [
5648            (PathBuf::from("sleep"), vec!["60".into()]),
5649            (script, vec![]),
5650        ] {
5651            let spec = ModuleSpec {
5652                module_id: "image-identity".into(),
5653                program: program.clone(),
5654                args,
5655                env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
5656                    .into_iter()
5657                    .map(|key| (key.into(), dir.to_string_lossy().into_owned()))
5658                    .collect(),
5659                reserved: false,
5660                reserved_prefixes: vec![],
5661                protocol: ModuleProtocol::None,
5662                overlap: Default::default(),
5663            };
5664            let module = supervisor.spawn(spec).unwrap();
5665            #[cfg(target_os = "macos")]
5666            {
5667                // SETEXEC confirmation is asynchronous; the orphan record must
5668                // identify the final image, never the intermediate trampoline.
5669                let deadline = Instant::now() + Duration::from_secs(5);
5670                while crate::live_children::read_record(&record_path)
5671                    .unwrap()
5672                    .iter()
5673                    .all(|entry| entry.executable.is_none())
5674                {
5675                    assert!(Instant::now() < deadline, "module image was not confirmed");
5676                    tokio::time::sleep(Duration::from_millis(5)).await;
5677                }
5678            }
5679            let entry = crate::live_children::read_record(&record_path)
5680                .unwrap()
5681                .pop()
5682                .unwrap();
5683            let observed = subc_os::Process::open(entry.pid)
5684                .unwrap()
5685                .unwrap()
5686                .observe()
5687                .unwrap();
5688            let verdict = crate::live_children::identity_verdict(&entry, entry.pid, &observed);
5689            module.drain().await.unwrap();
5690            assert_eq!(
5691                verdict,
5692                crate::live_children::IdentityVerdict::Matches,
5693                "program {program:?}: recorded {entry:?}, observed {observed:?}"
5694            );
5695        }
5696    }
5697
5698    #[cfg(unix)]
5699    fn http_fixture(
5700        dir: &std::path::Path,
5701        url: &str,
5702        threshold: u32,
5703    ) -> crate::daemon_config::ConfiguredModule {
5704        let path = dir.join("subc.jsonc");
5705        std::fs::write(&path, serde_json::json!({"version": 1, "modules": {"http-process": {
5706            "program": "/bin/sleep", "args": ["60"], "protocol": "none",
5707            "env": {"XDG_DATA_HOME":dir,"XDG_RUNTIME_DIR":dir,"XDG_CONFIG_HOME":dir},
5708            "health": {"http":url,"cadence_ms":20,"deadline_ms":200,"failure_threshold":threshold},
5709            "restart": {"backoff_ms":1,"max_backoff_ms":1}, "drain_timeout_ms":10
5710        }}}).to_string()).unwrap();
5711        crate::daemon_config::load(&path)
5712            .unwrap()
5713            .unwrap()
5714            .modules
5715            .pop()
5716            .unwrap()
5717    }
5718
5719    #[cfg(unix)]
5720    async fn wait_http_health(module: &SupervisedModule, status: SupervisorHealthStatus) {
5721        timeout(Duration::from_secs(5), async {
5722            loop {
5723                if module.status().unwrap().health.status == status {
5724                    break;
5725                }
5726                sleep(Duration::from_millis(5)).await;
5727            }
5728        })
5729        .await
5730        .unwrap_or_else(|_| {
5731            panic!(
5732                "expected {status:?}, got {:?}",
5733                module.status().unwrap().health
5734            )
5735        });
5736    }
5737
5738    #[cfg(unix)]
5739    #[tokio::test]
5740    async fn http_health_status_flips_ok_failing_ok() {
5741        use tokio::io::{AsyncReadExt, AsyncWriteExt};
5742        let dir = cortexkit_test_support::ScratchDir::new("http-status-flips");
5743        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5744        let status = Arc::new(std::sync::atomic::AtomicU16::new(200));
5745        let serving_status = status.clone();
5746        let url = format!("http://{}/healthz", listener.local_addr().unwrap());
5747        let server = tokio::spawn(async move {
5748            loop {
5749                let (mut stream, _) = listener.accept().await.unwrap();
5750                let mut request = [0u8; 2048];
5751                let count = stream.read(&mut request).await.unwrap();
5752                assert!(count > 0, "a probe must send an HTTP request");
5753                let code = serving_status.load(std::sync::atomic::Ordering::SeqCst);
5754                let body = if code == 200 {
5755                    "ready"
5756                } else {
5757                    "{\"status\":\"unavailable\",\"error\":\"scratch failure\"}"
5758                };
5759                let response = format!("HTTP/1.1 {code} scratch\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
5760                let _ = stream.write_all(response.as_bytes()).await;
5761            }
5762        });
5763        let configured = http_fixture(&dir, &url, 1000);
5764        let module =
5765            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5766                .supervise_configured_with_health(
5767                    configured.module_spec(),
5768                    true,
5769                    configured.health,
5770                    configured.drain_timeout_ms,
5771                    configured.restart,
5772                )
5773                .unwrap();
5774        wait_http_health(&module, SupervisorHealthStatus::Ok).await;
5775        status.store(503, std::sync::atomic::Ordering::SeqCst);
5776        wait_http_health(&module, SupervisorHealthStatus::Failing).await;
5777        assert!(module
5778            .status()
5779            .unwrap()
5780            .health
5781            .detail
5782            .unwrap()
5783            .contains("scratch failure"));
5784        status.store(200, std::sync::atomic::Ordering::SeqCst);
5785        wait_http_health(&module, SupervisorHealthStatus::Ok).await;
5786        assert_eq!(module.status().unwrap().health.consecutive_failures, 0);
5787        let before = module.status().unwrap();
5788        let (spec, mut health) = module.configuration().unwrap();
5789        health.http = None;
5790        module
5791            .update_configuration(spec.clone(), health.clone(), Some(10))
5792            .await
5793            .unwrap();
5794        wait_http_health(&module, SupervisorHealthStatus::Unknown).await;
5795        health.http = Some(url);
5796        module
5797            .update_configuration(spec, health, Some(10))
5798            .await
5799            .unwrap();
5800        wait_http_health(&module, SupervisorHealthStatus::Ok).await;
5801        assert_eq!(
5802            module.status().unwrap().pid,
5803            before.pid,
5804            "changing a probe must apply live, not restart its process"
5805        );
5806        let (spec, mut health) = module.configuration().unwrap();
5807        health.failure_threshold = 2;
5808        module
5809            .update_configuration(spec, health, Some(10))
5810            .await
5811            .unwrap();
5812        status.store(503, std::sync::atomic::Ordering::SeqCst);
5813        timeout(Duration::from_secs(5), async {
5814            while module.status().unwrap().spawn_generation == before.spawn_generation {
5815                sleep(Duration::from_millis(5)).await;
5816            }
5817        })
5818        .await
5819        .expect("sustained HTTP 503 responses must trigger the health restart policy");
5820        module.drain().await.unwrap();
5821        server.abort();
5822    }
5823
5824    #[cfg(unix)]
5825    #[tokio::test]
5826    async fn http_health_no_listener_restarts_after_consecutive_failures() {
5827        let dir = cortexkit_test_support::ScratchDir::new("http-refused");
5828        let unused = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5829        let url = format!("http://{}/healthz", unused.local_addr().unwrap());
5830        drop(unused);
5831        let configured = http_fixture(&dir, &url, 2);
5832        let module =
5833            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5834                .supervise_configured_with_health(
5835                    configured.module_spec(),
5836                    true,
5837                    configured.health,
5838                    configured.drain_timeout_ms,
5839                    configured.restart,
5840                )
5841                .unwrap();
5842        let before = module.status().unwrap().spawn_generation;
5843        timeout(Duration::from_secs(5), async {
5844            loop {
5845                let status = module.status().unwrap();
5846                if status.spawn_generation > before {
5847                    assert!(status.lifetime_restarts > 0);
5848                    break;
5849                }
5850                sleep(Duration::from_millis(5)).await;
5851            }
5852        })
5853        .await
5854        .expect("sustained HTTP refusal must trigger the health restart policy");
5855        module.drain().await.unwrap();
5856    }
5857
5858    #[cfg(unix)]
5859    #[tokio::test]
5860    async fn http_health_timeout_honours_deadline() {
5861        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5862        let url = format!("http://{}/healthz", listener.local_addr().unwrap());
5863        let server = tokio::spawn(async move {
5864            let _held = listener.accept().await.unwrap();
5865            std::future::pending::<()>().await;
5866        });
5867        let error = timeout(
5868            Duration::from_secs(1),
5869            probe_http_health(&url, Duration::from_millis(10)),
5870        )
5871        .await
5872        .expect("the probe must enforce its own deadline")
5873        .unwrap_err();
5874        server.abort();
5875        assert!(error.to_string().contains("timed out"));
5876    }
5877
5878    #[cfg(unix)]
5879    #[tokio::test]
5880    async fn http_health_localhost_reaches_an_ipv6_only_listener() {
5881        use tokio::io::{AsyncReadExt, AsyncWriteExt};
5882        let listener = tokio::net::TcpListener::bind("[::1]:0").await.unwrap();
5883        let url = format!(
5884            "http://localhost:{}/healthz",
5885            listener.local_addr().unwrap().port()
5886        );
5887        let server = tokio::spawn(async move {
5888            let (mut stream, _) = listener.accept().await.unwrap();
5889            let mut request = [0u8; 2048];
5890            assert!(stream.read(&mut request).await.unwrap() > 0);
5891            stream
5892                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
5893                .await
5894                .unwrap();
5895        });
5896        // The deadline only bounds a hang. A probe that never tried the IPv6
5897        // address would be refused on 127.0.0.1 and fail at once, so a longer
5898        // deadline does not weaken the assertion; one second timed out under a
5899        // loaded parallel test run.
5900        assert_eq!(
5901            probe_http_health(&url, Duration::from_secs(10))
5902                .await
5903                .unwrap()
5904                .status,
5905            HealthStatus::Ok
5906        );
5907        server.await.unwrap();
5908    }
5909
5910    #[cfg(unix)]
5911    #[tokio::test]
5912    async fn http_health_timeout_keeps_partial_status_and_body() {
5913        use tokio::io::{AsyncReadExt, AsyncWriteExt};
5914        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5915        let url = format!("http://{}/healthz", listener.local_addr().unwrap());
5916        let server = tokio::spawn(async move {
5917            let (mut stream, _) = listener.accept().await.unwrap();
5918            let mut request = [0u8; 2048];
5919            assert!(stream.read(&mut request).await.unwrap() > 0);
5920            stream
5921                .write_all(
5922                    b"HTTP/1.1 503 Unavailable\r\nContent-Length: 100\r\n\r\npartial diagnostic",
5923                )
5924                .await
5925                .unwrap();
5926            std::future::pending::<()>().await;
5927        });
5928        let error = probe_http_health(&url, Duration::from_secs(1))
5929            .await
5930            .unwrap_err()
5931            .to_string();
5932        server.abort();
5933        assert!(
5934            error.contains("timed out")
5935                && error.contains("503 Unavailable")
5936                && error.contains("partial diagnostic"),
5937            "{error}"
5938        );
5939    }
5940
5941    #[cfg(unix)]
5942    #[tokio::test]
5943    async fn http_health_diagnostic_body_is_bounded_to_200_bytes() {
5944        use tokio::io::{AsyncReadExt, AsyncWriteExt};
5945        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5946        let url = format!("http://{}/healthz", listener.local_addr().unwrap());
5947        let server = tokio::spawn(async move {
5948            let (mut stream, _) = listener.accept().await.unwrap();
5949            let mut request = [0u8; 2048];
5950            assert!(stream.read(&mut request).await.unwrap() > 0);
5951            let body = format!("{}not-in-diagnostic", "x".repeat(400));
5952            let response = format!(
5953                "HTTP/1.1 503 Unavailable\r\nContent-Length: {}\r\n\r\n{body}",
5954                body.len()
5955            );
5956            stream.write_all(response.as_bytes()).await.unwrap();
5957        });
5958        let error = probe_http_health(&url, Duration::from_secs(1))
5959            .await
5960            .unwrap_err()
5961            .to_string();
5962        server.await.unwrap();
5963        assert!(error.ends_with(&"x".repeat(200)), "{error}");
5964        assert_eq!(error.split(": ").last().unwrap().len(), 200);
5965        assert!(!error.contains("not-in-diagnostic"));
5966    }
5967
5968    #[cfg(unix)]
5969    #[tokio::test]
5970    async fn http_health_real_nats_server_monitoring() {
5971        if std::process::Command::new("nats-server")
5972            .arg("--version")
5973            .env("XDG_DATA_HOME", std::env::temp_dir())
5974            .env("XDG_RUNTIME_DIR", std::env::temp_dir())
5975            .env("XDG_CONFIG_HOME", std::env::temp_dir())
5976            .output()
5977            .is_err()
5978        {
5979            eprintln!(
5980                "skipped: http_health_real_nats_server_monitoring (nats-server is not on PATH)"
5981            );
5982            return;
5983        }
5984        let dir = cortexkit_test_support::ScratchDir::new("nats-http-monitoring");
5985        let monitor = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5986        let port = monitor.local_addr().unwrap().port();
5987        let client = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5988        let client_port = client.local_addr().unwrap().port();
5989        let config = dir.join("server.conf");
5990        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();
5991        let mut configured = http_fixture(&dir, &format!("http://127.0.0.1:{port}/healthz"), 1000);
5992        configured.program = PathBuf::from("nats-server");
5993        configured.args = vec!["-c".into(), config.to_string_lossy().into_owned()];
5994        drop(monitor);
5995        drop(client);
5996        let module =
5997            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5998                .supervise_configured_with_health(
5999                    configured.module_spec(),
6000                    true,
6001                    configured.health,
6002                    configured.drain_timeout_ms,
6003                    configured.restart,
6004                )
6005                .unwrap();
6006        wait_http_health(&module, SupervisorHealthStatus::Ok).await;
6007        module.drain().await.unwrap();
6008    }
6009
6010    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
6011    async fn configuration_update_does_not_replace_captured_running_process_facts() {
6012        let supervisor =
6013            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
6014        let snapshot = stale_process_snapshot(ModuleState::Running, true);
6015        let initial = ModuleSpec {
6016            module_id: "rescan-preserves-spawn-facts".to_string(),
6017            program: PathBuf::from("/spawned/module"),
6018            args: Vec::new(),
6019            env: Vec::new(),
6020            reserved: false,
6021            reserved_prefixes: Vec::new(),
6022            protocol: ModuleProtocol::Subc,
6023            overlap: Default::default(),
6024        };
6025        let module = supervisor.supervised_module(
6026            initial.clone(),
6027            supervisor.runtime_config(),
6028            snapshot,
6029            None,
6030        );
6031        let before = module.status().unwrap();
6032        let mut replacement = initial;
6033        replacement.program = PathBuf::from("/rescanned/replacement-module");
6034
6035        module
6036            .update_configuration(replacement, HealthConfig::default(), None)
6037            .await
6038            .unwrap();
6039
6040        let after = module.status().unwrap();
6041        assert_eq!(after.pid, before.pid);
6042        assert_eq!(after.spawned_at_ms, before.spawned_at_ms);
6043        assert_eq!(after.spawned_from, before.spawned_from);
6044        drop(module);
6045    }
6046}
6047
6048fn unix_ms_now() -> u64 {
6049    SystemTime::now()
6050        .duration_since(UNIX_EPOCH)
6051        .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
6052        .unwrap_or(0)
6053}
6054
6055async fn supervise_loop(
6056    mut spec: ModuleSpec,
6057    mut runtime: SupervisorRuntimeConfig,
6058    registry: Arc<Registry>,
6059    process_liveness: Arc<SupervisorProcessLiveness>,
6060    snapshot: SharedSnapshot,
6061    mut child: Option<SupervisedChild>,
6062    mut commands: mpsc::Receiver<SupervisorCommand>,
6063) {
6064    let mut health_probe = HealthProbeRuntime::default();
6065    // Registry writes (including embedded callers) notify this module only.
6066    // Subscribe before the first refresh; watch retains changes that arrive
6067    // while commands or probes are running. No polling fallback is needed.
6068    let mut registration_changes = match registry.subscribe_module_changes(&spec.module_id) {
6069        Ok(changes) => Some(changes),
6070        Err(err) => {
6071            // A poisoned registry cannot accept further writes, so it cannot
6072            // recover via a timer. Keep exit and command handling alive.
6073            warn!(module_id = %spec.module_id, error = %err, "health prober could not subscribe to registry");
6074            None
6075        }
6076    };
6077    // All restart backoffs run here, including health and operator requests.
6078    // While one is pending the loop serves commands, so disable or drain can
6079    // cancel the replacement without spawning a process just to stop it.
6080    let mut pending_respawn: Option<PendingRespawn> = None;
6081    // Commands a swap handed back to run next (see `swap::SwapEnd`). Served
6082    // before anything else so a stop that interrupted a swap runs at once.
6083    let mut requeued: VecDeque<SupervisorCommand> = VecDeque::new();
6084    #[cfg(target_os = "linux")]
6085    let mut previous_instance_recheck = tokio::time::interval_at(
6086        Instant::now() + Duration::from_secs(5),
6087        Duration::from_secs(5),
6088    );
6089    #[cfg(target_os = "linux")]
6090    previous_instance_recheck.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
6091    loop {
6092        #[cfg(test)]
6093        {
6094            lock_snapshot(&snapshot).unwrap().actor_select = None;
6095        }
6096        #[cfg(target_os = "macos")]
6097        if let Some(active) = child.as_mut() {
6098            active.confirm_privacy_exec().await;
6099        }
6100        if let Some(scheduled) = runtime
6101            .scheduled_respawn
6102            .lock()
6103            .unwrap_or_else(|p| p.into_inner())
6104            .take()
6105        {
6106            pending_respawn = Some(scheduled);
6107        }
6108        if pending_respawn.is_some() && (child.is_some() || !respawn_still_pending(&snapshot)) {
6109            pending_respawn = None;
6110            cancel_deferred_reload(
6111                &runtime,
6112                &spec.module_id,
6113                "respawn cancelled by a supervisor command",
6114            );
6115        }
6116        if child.is_none()
6117            && pending_respawn.is_none()
6118            && lock_snapshot(&snapshot).is_ok_and(|state| state.waiting_for_previous_pid.is_none())
6119        {
6120            cancel_deferred_reload(
6121                &runtime,
6122                &spec.module_id,
6123                "respawn cancelled before a replacement was spawned",
6124            );
6125            let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6126                state.respawn_pending = false;
6127                state.coalesced_restart_pending = false;
6128                if matches!(
6129                    state.state,
6130                    ModuleState::Restarting
6131                        | ModuleState::Starting
6132                        | ModuleState::Draining
6133                        | ModuleState::Unresponsive
6134                ) {
6135                    error!(module_id = %spec.module_id, state = ?state.state,
6136                        "supervision operation ended without a child or pending respawn; marking failed so start can retry");
6137                    state.state = ModuleState::Failed;
6138                    clear_current_process_facts(state);
6139                }
6140            });
6141        }
6142        if let Some(command) = requeued.pop_front() {
6143            if !handle_supervisor_command(
6144                command,
6145                &mut spec,
6146                &mut runtime,
6147                &registry,
6148                &process_liveness,
6149                &snapshot,
6150                &mut child,
6151                &mut commands,
6152                &mut requeued,
6153            )
6154            .await
6155            {
6156                return;
6157            }
6158            if child.is_some() || !respawn_still_pending(&snapshot) {
6159                pending_respawn = None;
6160                let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6161                    state.respawn_pending = false
6162                });
6163            }
6164            continue;
6165        }
6166        if child.is_some() {
6167            #[cfg(test)]
6168            {
6169                lock_snapshot(&snapshot).unwrap().actor_turns += 1;
6170            }
6171            health_probe.refresh_registration(&spec, &runtime, &registry, &snapshot);
6172            let wake_after = health_probe.wake_after();
6173            let probe_sleep = async move {
6174                match wake_after {
6175                    Some(delay) => sleep(delay).await,
6176                    None => std::future::pending().await,
6177                }
6178            };
6179            tokio::pin!(probe_sleep);
6180            // A `protocol: "none"` child (a plain process that never registers
6181            // over the subc wire, such as nats-server) with no HTTP health
6182            // check has nothing to probe, so it waits only for its exit or a
6183            // supervisor command. A rescan that adds an HTTP check is a command.
6184            let wire_child = running_protocol(&spec, &snapshot) == ModuleProtocol::Subc;
6185            #[cfg(test)]
6186            {
6187                let registration_pending = wire_child
6188                    && registration_changes
6189                        .as_ref()
6190                        .is_some_and(|changes| changes.has_changed().unwrap());
6191                let mut state = lock_snapshot(&snapshot).unwrap();
6192                state.actor_select = (!registration_pending).then_some(ActorSelectCheckpoint {
6193                    generation: state.spawn_generation,
6194                    turn: state.actor_turns,
6195                    registered_connection: health_probe.registered_connection,
6196                    next_probe_at: health_probe.next_probe_at,
6197                    wake_after,
6198                });
6199            }
6200            let active_child = child.as_mut().expect("child checked above");
6201            tokio::select! {
6202                wait_result = active_child.wait() => {
6203                    #[cfg(test)]
6204                    { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6205                    // Every arm below that gives up on the CHILD must keep the
6206                    // supervision task itself alive (child = None, loop
6207                    // continues into command-serving mode). Returning here
6208                    // closes the command channel, which makes the module
6209                    // permanently unrestartable in-band: a clean child exit
6210                    // of an enabled module once wedged the fleet this way
6211                    // ('supervisor command channel is closed') and required a
6212                    // full daemon restart to recover.
6213                    let exit_report = match wait_result {
6214                        Ok(status) => classify_reaped_child_exit(&snapshot, active_child, &status),
6215                        Err(err) => {
6216                            active_child.drain_stderr(&spec.module_id).await;
6217                            fail_after_wait_error(
6218                                &spec.module_id,
6219                                &snapshot,
6220                                &runtime.terminal_ring,
6221                                &runtime.spawn_events,
6222                                format!("failed to wait for supervised module: {err}"),
6223                            );
6224                            untrack_if_registration_released(
6225                                &process_liveness,
6226                                &registry,
6227                                &spec.module_id,
6228                                &snapshot,
6229                            );
6230                            error!(module_id = %spec.module_id, error = %err, "failed to wait for supervised module");
6231                            child = None;
6232                            continue;
6233                        }
6234                    };
6235                    active_child.drain_stderr(&spec.module_id).await;
6236
6237                    let next = on_child_exit(
6238                        &spec,
6239                        runtime.restart_policy,
6240                        &registry,
6241                        &snapshot,
6242                        &runtime.terminal_ring,
6243                        &runtime.spawn_events,
6244                        &runtime.child_roster,
6245                        exit_report,
6246                    ).await;
6247                    // The exit is recorded, so a daemon shutdown may stop
6248                    // waiting for this child (see `SupervisedChild::wait`).
6249                    active_child.release_roster();
6250                    match next {
6251                        NextAction::Stop { registration_released } => {
6252                            if registration_released {
6253                                process_liveness.untrack_if_current(&spec.module_id, &snapshot);
6254                            }
6255                            child = None;
6256                        }
6257                        NextAction::Restart { schedule } => {
6258                            let delay = schedule.map_or(
6259                                runtime.restart_policy.delay_for_restart(0),
6260                                |schedule| schedule.delay,
6261                            );
6262                            if let Some(schedule) = schedule {
6263                                log_crash_respawn(&spec.module_id, schedule);
6264                            }
6265                            // The exited child is fully recorded at this point,
6266                            // so release it and count the backoff down in the
6267                            // command-serving branch below rather than sleeping
6268                            // here: commands cannot be received from inside this
6269                            // select arm, and an operator disable or drain that
6270                            // arrives during the backoff must cancel the pending
6271                            // respawn instead of waiting for it to spawn first.
6272                            child = None;
6273                            pending_respawn = Some(PendingRespawn { deadline: Instant::now() + delay, kind: RespawnKind::Spawn });
6274                             let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| state.respawn_pending = true);
6275                        }
6276                    }
6277                }
6278                command = commands.recv() => {
6279                    #[cfg(test)]
6280                    { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6281                    let Some(command) = command else {
6282                        return;
6283                    };
6284                    if !handle_supervisor_command(
6285                        command,
6286                        &mut spec,
6287                        &mut runtime,
6288                        &registry,
6289                        &process_liveness,
6290                        &snapshot,
6291                        &mut child,
6292                        &mut commands,
6293                        &mut requeued,
6294                    ).await {
6295                        return;
6296                    }
6297                }
6298                _ = async {
6299                    match registration_changes.as_mut() {
6300                        Some(changes) => { let _ = changes.changed().await; }
6301                        None => std::future::pending().await,
6302                    }
6303                }, if wire_child => {
6304                    #[cfg(test)]
6305                    { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6306                }
6307                _ = &mut probe_sleep => {
6308                    #[cfg(test)]
6309                    { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6310                    if health_probe.due() {
6311                        run_health_probe_cycle(
6312                            &spec,
6313                            &runtime,
6314                            &registry,
6315                            &process_liveness,
6316                            &snapshot,
6317                            &mut child,
6318                        ).await;
6319                        if child.is_some() {
6320                            health_probe.schedule_next(&spec, runtime.health.cadence);
6321                        }
6322                    }
6323                }
6324            }
6325        } else if let Some(pending) = pending_respawn {
6326            tokio::select! {
6327                _ = sleep_until(pending.deadline) => {
6328                    pending_respawn = None;
6329                let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| state.respawn_pending = false);
6330                    // A command handled below while the backoff elapsed may
6331                    // have stopped the module; never respawn past an operator's
6332                    // disable or drain.
6333                    if !respawn_still_pending(&snapshot) {
6334                        continue;
6335                    }
6336                    // The daemon began shutting down during the backoff: the
6337                    // spawn would be refused anyway, and refusing it here
6338                    // leaves the module stopped instead of reporting a
6339                    // failed restart.
6340                    if runtime.child_roster.is_closed() {
6341                        let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6342                            state.state = ModuleState::Stopped;
6343                        });
6344                        debug!(module_id = %spec.module_id, "crash respawn cancelled by daemon shutdown");
6345                        continue;
6346                    }
6347                    if let Err(err) = release_dead_registration(
6348                        &registry,
6349                        runtime.forwarding.as_deref(),
6350                        &snapshot,
6351                        &spec.module_id,
6352                    ).await {
6353                        fail_snapshot(&snapshot, Some(&spec.module_id), None);
6354                        error!(module_id = %spec.module_id, error = %err, "registration did not release before restart");
6355                        continue;
6356                    }
6357
6358                    if matches!(pending.kind, RespawnKind::Reload) {
6359                        let reply = runtime.deferred_reload_reply.lock().unwrap_or_else(|p| p.into_inner()).take();
6360                        let result = finish_reload_child(&spec, &runtime, &registry, &process_liveness, &snapshot, &mut child).await;
6361                        if let Some(reply) = reply { let _ = reply.send(result); }
6362                        continue;
6363                    }
6364                    process_liveness.track(spec.module_id.clone(), Arc::clone(&snapshot));
6365                    match spawn_and_mark_running(&spec, &runtime, &snapshot) {
6366                        Ok(next_child) => {
6367                            child = Some(next_child);
6368                            debug!(module_id = %spec.module_id, "supervised module restarted after crash");
6369                        }
6370                        Err(err) => {
6371                            fail_snapshot(&snapshot, Some(&spec.module_id), None);
6372                            process_liveness.untrack_if_current(&spec.module_id, &snapshot);
6373                            error!(module_id = %spec.module_id, error = %err, "failed to restart supervised module");
6374                        }
6375                    }
6376                }
6377                command = commands.recv() => {
6378                    let Some(command) = command else {
6379                        return;
6380                    };
6381                    if !handle_supervisor_command(
6382                        command,
6383                        &mut spec,
6384                        &mut runtime,
6385                        &registry,
6386                        &process_liveness,
6387                        &snapshot,
6388                        &mut child,
6389                        &mut commands,
6390                        &mut requeued,
6391                    ).await {
6392                        return;
6393                    }
6394                    // Reconcile the pending respawn with what the command did:
6395                    // a start may already have spawned a fresh child,
6396                    // while a disable or drain moved the snapshot out of the
6397                    // state the respawn was counting down from.
6398                    if child.is_some() || !respawn_still_pending(&snapshot) {
6399                        pending_respawn = None;
6400                let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| state.respawn_pending = false);
6401                    }
6402                }
6403            }
6404        } else {
6405            #[cfg(target_os = "linux")]
6406            let command = if lock_snapshot(&snapshot)
6407                .is_ok_and(|state| state.waiting_for_previous_pid.is_some())
6408            {
6409                tokio::select! {
6410                    command = commands.recv() => command,
6411                    _ = previous_instance_recheck.tick() => {
6412                        if runtime.child_roster.previous_instance_pid(&spec.module_id).is_none() {
6413                            let enabled = lock_snapshot(&snapshot).is_ok_and(|state| state.enabled);
6414                            if enabled && !runtime.child_roster.is_closed() {
6415                                process_liveness.track(spec.module_id.clone(), Arc::clone(&snapshot));
6416                                match spawn_and_mark_running(&spec, &runtime, &snapshot) {
6417                                    Ok(next_child) => child = Some(next_child),
6418                                    Err(error) => {
6419                                        fail_snapshot(&snapshot, Some(&spec.module_id), None);
6420                                        error!(module_id = %spec.module_id, %error, "failed to start module after its previous instance exited");
6421                                    }
6422                                }
6423                            } else {
6424                                let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6425                                    state.waiting_for_previous_pid = None;
6426                                });
6427                            }
6428                        }
6429                        continue;
6430                    }
6431                }
6432            } else {
6433                commands.recv().await
6434            };
6435            #[cfg(not(target_os = "linux"))]
6436            let command = commands.recv().await;
6437            let Some(command) = command else {
6438                return;
6439            };
6440            if !handle_supervisor_command(
6441                command,
6442                &mut spec,
6443                &mut runtime,
6444                &registry,
6445                &process_liveness,
6446                &snapshot,
6447                &mut child,
6448                &mut commands,
6449                &mut requeued,
6450            )
6451            .await
6452            {
6453                return;
6454            }
6455        }
6456    }
6457}
6458
6459fn log_crash_respawn(module_id: &str, schedule: CrashRestartSchedule) {
6460    info!(
6461        module_id,
6462        restart_in_window = schedule.restart_in_window,
6463        delay_ms = schedule.delay.as_millis() as u64,
6464        "respawning after crash"
6465    );
6466}
6467
6468/// Whether the respawn a backoff was counting down to is still wanted. A
6469/// disable or drain handled while the backoff elapsed moves the snapshot out
6470/// of `Restarting`, and the operator's stop must win over the pending respawn,
6471/// so every sleep-then-spawn path re-validates against the live snapshot
6472/// instead of assuming the state it left behind still holds.
6473fn respawn_still_pending(snapshot: &SharedSnapshot) -> bool {
6474    matches!(
6475        lock_snapshot(snapshot),
6476        Ok(state) if state.enabled && state.state == ModuleState::Restarting
6477    )
6478}
6479
6480enum NextAction {
6481    Stop {
6482        registration_released: bool,
6483    },
6484    Restart {
6485        schedule: Option<CrashRestartSchedule>,
6486    },
6487}
6488
6489#[allow(clippy::too_many_arguments)]
6490async fn handle_supervisor_command(
6491    command: SupervisorCommand,
6492    spec: &mut ModuleSpec,
6493    runtime: &mut SupervisorRuntimeConfig,
6494    registry: &Arc<Registry>,
6495    process_liveness: &SupervisorProcessLiveness,
6496    snapshot: &SharedSnapshot,
6497    child: &mut Option<SupervisedChild>,
6498    commands: &mut mpsc::Receiver<SupervisorCommand>,
6499    requeued: &mut VecDeque<SupervisorCommand>,
6500) -> bool {
6501    #[cfg(target_os = "linux")]
6502    let command = if let Some(pid) = runtime.child_roster.previous_instance_pid(&spec.module_id) {
6503        match command {
6504            SupervisorCommand::Restart { reply, .. }
6505            | SupervisorCommand::Reload { reply }
6506            | SupervisorCommand::Swap { reply, .. } => {
6507                let _ = reply.send(Err(previous_instance_error(spec, pid)));
6508                return true;
6509            }
6510            SupervisorCommand::SetEnabled {
6511                enabled: true,
6512                reply,
6513            } => {
6514                let _ = reply.send(Err(previous_instance_error(spec, pid)));
6515                return true;
6516            }
6517            other => other,
6518        }
6519    } else {
6520        command
6521    };
6522    match command {
6523        SupervisorCommand::Drain { reply } => {
6524            // A plain stop runs no forwarding drain, so nothing reaches the
6525            // module over its connection before the wait: ask by signal.
6526            let result = drain_optional_child(
6527                &spec.module_id,
6528                spec.protocol,
6529                StopNotice::NotSent,
6530                registry,
6531                runtime.forwarding.as_deref(),
6532                snapshot,
6533                &runtime.terminal_ring,
6534                &runtime.spawn_events,
6535                child,
6536                runtime.drain_timeout,
6537                ModuleState::Stopped,
6538                None,
6539            )
6540            .await;
6541            let registration_released = result.is_ok();
6542            let _ = reply.send(result);
6543            if registration_released {
6544                process_liveness.untrack_if_current(&spec.module_id, snapshot);
6545            }
6546            false
6547        }
6548        SupervisorCommand::Retire { reply } => {
6549            let result = async {
6550                let stop_notice = begin_forwarding_drain_if_configured(
6551                    spec,
6552                    runtime,
6553                    registry,
6554                    snapshot,
6555                    None,
6556                    RouteCloseReason::Disable,
6557                )
6558                .await?;
6559                drain_optional_child(
6560                    &spec.module_id,
6561                    spec.protocol,
6562                    stop_notice,
6563                    registry,
6564                    runtime.forwarding.as_deref(),
6565                    snapshot,
6566                    &runtime.terminal_ring,
6567                    &runtime.spawn_events,
6568                    child,
6569                    runtime.drain_timeout,
6570                    ModuleState::Stopped,
6571                    None,
6572                )
6573                .await
6574            }
6575            .await;
6576            let registration_released = result.is_ok();
6577            let _ = reply.send(result);
6578            if registration_released {
6579                process_liveness.untrack_if_current(&spec.module_id, snapshot);
6580            }
6581            false
6582        }
6583        SupervisorCommand::Restart {
6584            drain_timeout_ms,
6585            received_at_generation,
6586            queued_at,
6587            reply,
6588        } => {
6589            // Without this line a restart that waited in the queue (behind a
6590            // health probe cycle or another command) was invisible: the log
6591            // showed only the drain timing out, minutes after the operator's call.
6592            info!(
6593                module_id = %spec.module_id,
6594                queued_ms = u64::try_from(queued_at.elapsed().as_millis()).unwrap_or(u64::MAX),
6595                "restart command dequeued"
6596            );
6597            // ACK AT INITIATION, not completion. The blocking form deadlocked any
6598            // caller whose own request lane rides the module being restarted: the
6599            // caller's in-flight request keeps the drain from quiescing, the drain
6600            // keeps the restart from completing, and the completion keeps the reply
6601            // from releasing the caller — so the drain always timed out and cut the
6602            // initiator with a GOODBYE, even on a healthy module. Replying once the
6603            // restart is validated lets a self-lane caller settle, which is exactly
6604            // what makes the drain succeed. Completion is observable via
6605            // supervisor.list / module status; a post-ack failure lands the module
6606            // in a visible terminal state below rather than in a reply nobody can
6607            // receive.
6608            let validation = match lock_snapshot(snapshot) {
6609                Ok(state) if !state.enabled => Err(SuperviseError::Disabled {
6610                    module_id: spec.module_id.clone(),
6611                }),
6612                Ok(_) => Ok(()),
6613                Err(err) => Err(err),
6614            };
6615            let initiated = validation.is_ok();
6616            let _ = reply.send(validation);
6617            // A restart asks for a fresh process. Commands run one at a time,
6618            // so a restart queued behind another restart (two operator calls
6619            // in quick succession) is dequeued the moment the first one has
6620            // spawned its replacement -- before that process has sent HELLO.
6621            // Running it would drain and kill the process the first restart
6622            // just produced, which is the opposite of what both callers asked
6623            // for. If a process spawned after this request was received is
6624            // still supervised, the request is already satisfied. Not when the
6625            // configuration changed since that spawn: then the newer process
6626            // predates the spec this restart may exist to apply.
6627            let satisfied_by_generation = if initiated && child.is_some() {
6628                lock_snapshot(snapshot).ok().and_then(|state| {
6629                    (state.spawn_generation > received_at_generation
6630                        && !state.configuration_updated_since_spawn)
6631                        .then_some(state.spawn_generation)
6632                })
6633            } else {
6634                None
6635            };
6636            let satisfied_by_pending = initiated
6637                && child.is_none()
6638                && lock_snapshot(snapshot).ok().is_some_and(|mut state| {
6639                    let pending = state.respawn_pending && !state.configuration_updated_since_spawn;
6640                    if pending {
6641                        state.coalesced_restart_pending = true;
6642                    }
6643                    pending
6644                });
6645            if satisfied_by_pending {
6646                debug!(module_id = %spec.module_id, "restart coalesced into the pending replacement");
6647            } else if let Some(generation) = satisfied_by_generation {
6648                info!(
6649                    module_id = %spec.module_id,
6650                    received_at_generation,
6651                    "restart already satisfied by generation {generation}; not restarting again"
6652                );
6653            } else if initiated {
6654                // Precedence: this restart's operator override, else the module's
6655                // configured budget (already resolved into the runtime).
6656                let drain_timeout = drain_timeout_ms
6657                    .map(Duration::from_millis)
6658                    .unwrap_or(runtime.drain_timeout);
6659                if let Err(err) = restart_child(
6660                    spec,
6661                    runtime,
6662                    registry,
6663                    process_liveness,
6664                    snapshot,
6665                    child,
6666                    drain_timeout,
6667                )
6668                .await
6669                {
6670                    warn!(
6671                        module_id = %spec.module_id,
6672                        error = %err,
6673                        "operator restart failed after initiation ack; module state carries the outcome"
6674                    );
6675                    let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
6676                        state.state = ModuleState::Failed;
6677                        clear_current_process_facts(state);
6678                    });
6679                }
6680            }
6681            true
6682        }
6683        SupervisorCommand::Reload { reply } => {
6684            let result =
6685                reload_child(spec, runtime, registry, process_liveness, snapshot, child).await;
6686            if result.is_ok()
6687                && runtime
6688                    .scheduled_respawn
6689                    .lock()
6690                    .unwrap_or_else(|p| p.into_inner())
6691                    .as_ref()
6692                    .is_some_and(|pending| matches!(pending.kind, RespawnKind::Reload))
6693            {
6694                *runtime
6695                    .deferred_reload_reply
6696                    .lock()
6697                    .unwrap_or_else(|p| p.into_inner()) = Some(reply);
6698            } else {
6699                let _ = reply.send(result);
6700            }
6701            true
6702        }
6703        SupervisorCommand::SetEnabled { enabled, reply } => {
6704            let result = set_child_enabled(
6705                spec,
6706                runtime,
6707                registry,
6708                process_liveness,
6709                snapshot,
6710                child,
6711                enabled,
6712            )
6713            .await;
6714            let _ = reply.send(result);
6715            true
6716        }
6717        SupervisorCommand::UpdateConfiguration {
6718            spec: next_spec,
6719            health,
6720            drain_timeout_ms,
6721            reply,
6722        } => {
6723            if let Some(handle) = &runtime.supervisor_handle {
6724                handle.apply_identity_configuration(&next_spec);
6725            }
6726            *spec = next_spec;
6727            let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
6728                state.configuration_updated_since_spawn = true;
6729            });
6730            let health_changed = runtime.health != health;
6731            runtime.health = health;
6732            // Reset the cadence and old endpoint's failure streak on a live
6733            // health-policy change rather than waiting for its old deadline.
6734            if health_changed {
6735                let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
6736                    state.health = ModuleHealthStatus::default();
6737                });
6738            }
6739            runtime.drain_timeout = drain_timeout_ms
6740                .map(Duration::from_millis)
6741                .unwrap_or(runtime.default_drain_timeout);
6742            *runtime
6743                .effective_drain_timeout
6744                .lock()
6745                .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.drain_timeout;
6746            let _ = reply.send(());
6747            true
6748        }
6749        SupervisorCommand::Swap {
6750            ready_timeout,
6751            reply,
6752        } => {
6753            let end = swap::run_swap(
6754                spec,
6755                runtime,
6756                registry,
6757                process_liveness,
6758                snapshot,
6759                child,
6760                commands,
6761                ready_timeout.unwrap_or(DEFAULT_SWAP_READY_TIMEOUT),
6762                reply,
6763            )
6764            .await;
6765            requeued.extend(end.requeue);
6766            true
6767        }
6768    }
6769}
6770
6771async fn restart_child(
6772    spec: &ModuleSpec,
6773    runtime: &SupervisorRuntimeConfig,
6774    registry: &Registry,
6775    process_liveness: &SupervisorProcessLiveness,
6776    snapshot: &SharedSnapshot,
6777    child: &mut Option<SupervisedChild>,
6778    drain_timeout: Duration,
6779) -> Result<(), SuperviseError> {
6780    // Restart cycles a running module; it must not silently start a disabled one.
6781    if !lock_snapshot(snapshot)?.enabled {
6782        return Err(SuperviseError::Disabled {
6783            module_id: spec.module_id.clone(),
6784        });
6785    }
6786    let stop_notice = begin_forwarding_drain_with_timeout(
6787        spec,
6788        runtime,
6789        registry,
6790        snapshot,
6791        None,
6792        RouteCloseReason::Restart,
6793        drain_timeout,
6794    )
6795    .await?;
6796
6797    if child.is_some() {
6798        drain_optional_child(
6799            &spec.module_id,
6800            spec.protocol,
6801            stop_notice,
6802            registry,
6803            runtime.forwarding.as_deref(),
6804            snapshot,
6805            &runtime.terminal_ring,
6806            &runtime.spawn_events,
6807            child,
6808            drain_timeout,
6809            ModuleState::Restarting,
6810            Some(true),
6811        )
6812        .await?;
6813    } else {
6814        update_snapshot(snapshot, Some(&spec.module_id), |state| {
6815            state.enabled = true;
6816            state.state = ModuleState::Restarting;
6817            clear_current_process_facts(state);
6818        })?;
6819        release_dead_registration(
6820            registry,
6821            runtime.forwarding.as_deref(),
6822            snapshot,
6823            &spec.module_id,
6824        )
6825        .await?;
6826    }
6827
6828    reset_restart_count(snapshot, &spec.module_id)?;
6829    process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
6830    schedule_respawn(
6831        runtime,
6832        snapshot,
6833        &spec.module_id,
6834        runtime.restart_policy.backoff,
6835        RespawnKind::Spawn,
6836    )
6837}
6838
6839async fn reload_child(
6840    spec: &ModuleSpec,
6841    runtime: &SupervisorRuntimeConfig,
6842    registry: &Registry,
6843    process_liveness: &SupervisorProcessLiveness,
6844    snapshot: &SharedSnapshot,
6845    child: &mut Option<SupervisedChild>,
6846) -> Result<(), SuperviseError> {
6847    // Reload cycles a running module; it must not silently start a disabled one.
6848    if !lock_snapshot(snapshot)?.enabled {
6849        return Err(SuperviseError::Disabled {
6850            module_id: spec.module_id.clone(),
6851        });
6852    }
6853    let stop_notice = begin_forwarding_drain(
6854        spec,
6855        runtime,
6856        registry,
6857        snapshot,
6858        Some(true),
6859        RouteCloseReason::Reload,
6860    )
6861    .await?;
6862
6863    if child.is_some() {
6864        drain_optional_child(
6865            &spec.module_id,
6866            spec.protocol,
6867            stop_notice,
6868            registry,
6869            runtime.forwarding.as_deref(),
6870            snapshot,
6871            &runtime.terminal_ring,
6872            &runtime.spawn_events,
6873            child,
6874            runtime.drain_timeout,
6875            ModuleState::Restarting,
6876            Some(true),
6877        )
6878        .await?;
6879    } else {
6880        update_snapshot(snapshot, Some(&spec.module_id), |state| {
6881            state.enabled = true;
6882            state.state = ModuleState::Restarting;
6883            clear_current_process_facts(state);
6884        })?;
6885        release_dead_registration(
6886            registry,
6887            runtime.forwarding.as_deref(),
6888            snapshot,
6889            &spec.module_id,
6890        )
6891        .await?;
6892    }
6893
6894    reset_restart_count(snapshot, &spec.module_id)?;
6895    process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
6896    schedule_respawn(
6897        runtime,
6898        snapshot,
6899        &spec.module_id,
6900        runtime.restart_policy.backoff,
6901        RespawnKind::Reload,
6902    )
6903}
6904
6905async fn finish_reload_child(
6906    spec: &ModuleSpec,
6907    runtime: &SupervisorRuntimeConfig,
6908    registry: &Registry,
6909    process_liveness: &SupervisorProcessLiveness,
6910    snapshot: &SharedSnapshot,
6911    child: &mut Option<SupervisedChild>,
6912) -> Result<(), SuperviseError> {
6913    process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
6914    let next_child = match spawn_and_mark_running(spec, runtime, snapshot) {
6915        Ok(next_child) => next_child,
6916        Err(err) => {
6917            return handle_reload_spawn_failure(
6918                spec,
6919                runtime,
6920                process_liveness,
6921                snapshot,
6922                child,
6923                format!("new child failed to spawn: {err}"),
6924            )
6925            .await;
6926        }
6927    };
6928    *child = Some(next_child);
6929
6930    let wait_outcome = {
6931        let active_child = child.as_mut().expect("new reload child was just stored");
6932        wait_for_registration_after_reload(
6933            registry,
6934            &spec.module_id,
6935            snapshot,
6936            active_child,
6937            REGISTRY_RELEASE_TIMEOUT,
6938        )
6939        .await?
6940    };
6941
6942    match wait_outcome {
6943        RegistrationWaitOutcome::Registered => {
6944            debug!(module_id = %spec.module_id, "supervised module reloaded and registered");
6945            Ok(())
6946        }
6947        RegistrationWaitOutcome::Exited(exit_report) => {
6948            if let Some(active_child) = child.as_mut() {
6949                active_child.drain_stderr(&spec.module_id).await;
6950            }
6951            // Keep the reaped child's roster guard until its terminal is written.
6952            // Shutdown waits on that guard, not on the child Option used for respawn.
6953            let mut exited_child = child.take().expect("exited reload child is still stored");
6954            #[cfg(test)]
6955            if let Some(gate) = &runtime.test_reload_exit_record_gate {
6956                gate.reached.notify_one();
6957                gate.resume.notified().await;
6958            }
6959            let result = handle_reload_child_registration_failure(
6960                spec,
6961                runtime,
6962                registry,
6963                process_liveness,
6964                snapshot,
6965                child,
6966                ReloadRegistrationFailure {
6967                    exit_report: registration_failure_exit_report(exit_report),
6968                    reason: exited_child
6969                        .spawn_failure
6970                        .clone()
6971                        .unwrap_or_else(|| "new child exited before registering".to_string()),
6972                },
6973            )
6974            .await;
6975            exited_child.release_roster();
6976            result
6977        }
6978        RegistrationWaitOutcome::TimedOut => {
6979            let mut timed_out_child = child
6980                .take()
6981                .expect("timed-out reload child is still running");
6982            timed_out_child
6983                .start_kill()
6984                .map_err(|source| SuperviseError::Kill {
6985                    module_id: spec.module_id.clone(),
6986                    source,
6987                })?;
6988            let status = timed_out_child
6989                .wait()
6990                .await
6991                .map_err(|source| SuperviseError::Wait {
6992                    module_id: spec.module_id.clone(),
6993                    source,
6994                })?;
6995            timed_out_child.drain_stderr(&spec.module_id).await;
6996            handle_reload_child_registration_failure(
6997                spec,
6998                runtime,
6999                registry,
7000                process_liveness,
7001                snapshot,
7002                child,
7003                ReloadRegistrationFailure {
7004                    exit_report: registration_failure_exit_report(classify_reaped_child_exit(
7005                        snapshot,
7006                        &timed_out_child,
7007                        &status,
7008                    )),
7009                    reason: format!(
7010                        "new child did not register within {:?}",
7011                        REGISTRY_RELEASE_TIMEOUT
7012                    ),
7013                },
7014            )
7015            .await
7016        }
7017    }
7018}
7019
7020async fn set_child_enabled(
7021    spec: &ModuleSpec,
7022    runtime: &SupervisorRuntimeConfig,
7023    registry: &Registry,
7024    process_liveness: &SupervisorProcessLiveness,
7025    snapshot: &SharedSnapshot,
7026    child: &mut Option<SupervisedChild>,
7027    enabled: bool,
7028) -> Result<bool, SuperviseError> {
7029    let (current_enabled, current_state, respawn_pending) = {
7030        let state = lock_snapshot(snapshot)?;
7031        (state.enabled, state.state, state.respawn_pending)
7032    };
7033    // `start` (enable on an already-enabled module) heals TERMINAL states instead
7034    // of no-op'ing: a module whose restart budget exhausted (Failed) or that exited
7035    // clean (Stopped) has no live process and no other in-band recovery — the
7036    // operator's start is the explicit recovery act and resets the budget. Without
7037    // this arm the only revival was subc-probe --supervisor-restart in a terminal,
7038    // which the 2026-07-14 aft outage proved is a trap when the failed module is
7039    // the one providing every agent's shell.
7040    let revive_terminal = enabled
7041        && current_enabled
7042        && child.is_none()
7043        && (matches!(current_state, ModuleState::Failed | ModuleState::Stopped)
7044            || (current_state == ModuleState::Restarting && !respawn_pending));
7045    if current_enabled == enabled && !revive_terminal {
7046        return Ok(false);
7047    }
7048
7049    if enabled {
7050        update_snapshot(snapshot, Some(&spec.module_id), |state| {
7051            state.enabled = true;
7052            state.state = ModuleState::Starting;
7053            clear_current_process_facts(state);
7054        })?;
7055        #[cfg(test)]
7056        if runtime.test_seed_stale_facts_before_enable_spawn {
7057            update_snapshot(snapshot, Some(&spec.module_id), |state| {
7058                state.process_alive = true;
7059                state.pid = Some(41);
7060                state.spawned_at_ms = Some(42);
7061                state.spawned_from = Some(PathBuf::from("/spawned/module"));
7062                state.spawned_file_identity = Some(SpawnedFileIdentity {
7063                    device: 43,
7064                    inode: 44,
7065                    #[cfg(windows)]
7066                    windows_image: None,
7067                });
7068            })?;
7069        }
7070        release_dead_registration(
7071            registry,
7072            runtime.forwarding.as_deref(),
7073            snapshot,
7074            &spec.module_id,
7075        )
7076        .await?;
7077        reset_restart_count(snapshot, &spec.module_id)?;
7078        process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
7079        let next_child = match spawn_and_mark_running(spec, runtime, snapshot) {
7080            Ok(next_child) => next_child,
7081            Err(err) => {
7082                if let Err(state_err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
7083                    state.state = ModuleState::Failed;
7084                    clear_current_process_facts(state);
7085                }) {
7086                    error!(module_id = %spec.module_id, error = %state_err, "failed to record enable spawn failure");
7087                }
7088                process_liveness.untrack_if_current(&spec.module_id, snapshot);
7089                return Err(err);
7090            }
7091        };
7092        *child = Some(next_child);
7093        debug!(module_id = %spec.module_id, "supervised module enabled");
7094        Ok(true)
7095    } else {
7096        let stop_notice = begin_forwarding_drain_if_configured(
7097            spec,
7098            runtime,
7099            registry,
7100            snapshot,
7101            Some(false),
7102            RouteCloseReason::Disable,
7103        )
7104        .await?;
7105        drain_optional_child(
7106            &spec.module_id,
7107            spec.protocol,
7108            stop_notice,
7109            registry,
7110            runtime.forwarding.as_deref(),
7111            snapshot,
7112            &runtime.terminal_ring,
7113            &runtime.spawn_events,
7114            child,
7115            runtime.drain_timeout,
7116            ModuleState::Disabled,
7117            Some(false),
7118        )
7119        .await?;
7120        debug!(module_id = %spec.module_id, "supervised module disabled");
7121        Ok(true)
7122    }
7123}
7124
7125#[allow(clippy::too_many_arguments)]
7126async fn on_child_exit(
7127    spec: &ModuleSpec,
7128    policy: RestartPolicy,
7129    registry: &Registry,
7130    snapshot: &SharedSnapshot,
7131    terminal_ring: &Arc<Mutex<TerminalRing>>,
7132    spawn_events: &SpawnEventFeed,
7133    roster: &ChildRoster,
7134    exit_report: ExitReport,
7135) -> NextAction {
7136    // Once the daemon has begun shutting down, no exit is a crash to recover
7137    // from: the module is exiting because the daemon is going away (EOF on its
7138    // connection, or a service manager signalling the whole cgroup). Record it
7139    // as such and never schedule a respawn, which would only start a process
7140    // for the shutdown to end again.
7141    if roster.is_closed() {
7142        return on_child_exit_during_daemon_shutdown(
7143            spec,
7144            registry,
7145            snapshot,
7146            terminal_ring,
7147            spawn_events,
7148            exit_report,
7149        )
7150        .await;
7151    }
7152    // Every stop the supervisor itself asks for (operator stop, disable,
7153    // restart, reload, swap, a health restart, a drain that runs out of budget)
7154    // takes the child out of the supervise loop and reaps it in
7155    // `drain_child_to_state`, and daemon shutdown is handled above. So an exit
7156    // that reaches this point was not requested by the daemon.
7157    //
7158    // For a subc-wire module a clean exit is still a stop: those modules are
7159    // written to re-raise SIGTERM, so a stray outside signal already reads as a
7160    // crash, and exiting 0 is a deliberate choice the module made. A
7161    // `protocol: "none"` module is a stock program we cannot change, and many
7162    // of them (nats-server among them) exit 0 on SIGTERM. Treating that as a
7163    // stop would leave the module down for good after any stray signal, so it
7164    // goes through the crash path instead: it spends restart budget, respawns
7165    // with the crash backoff, and ends `failed` when the budget runs out.
7166    let unrequested_clean_exit_of_protocol_none = exit_report.kind == ExitKind::Clean
7167        && running_protocol(spec, snapshot) == ModuleProtocol::None;
7168    match exit_report.kind {
7169        ExitKind::Clean if !unrequested_clean_exit_of_protocol_none => {
7170            info!(
7171                module_id = %spec.module_id,
7172                exit_code = ?exit_report.code,
7173                exit_signal = ?exit_report.signal,
7174                "supervised module exited cleanly"
7175            );
7176            if let Err(err) = publish_after_terminal_record(
7177                &spec.module_id,
7178                || {
7179                    record_terminal(
7180                        &spec.module_id,
7181                        terminal_ring,
7182                        spawn_events,
7183                        &exit_report,
7184                        TerminalDisposition::Stopped,
7185                    )
7186                },
7187                || {
7188                    update_snapshot(snapshot, Some(&spec.module_id), |state| {
7189                        state.state = ModuleState::Stopped;
7190                        clear_current_process_facts(state);
7191                        state.last_exit = Some(exit_report.clone());
7192                    })
7193                },
7194            ) {
7195                error!(module_id = %spec.module_id, error = %err, "failed to publish clean module exit");
7196            }
7197            let registration_released = match wait_for_registration_release(
7198                registry,
7199                &spec.module_id,
7200                REGISTRY_RELEASE_TIMEOUT,
7201            )
7202            .await
7203            {
7204                Ok(()) => true,
7205                Err(err) => {
7206                    warn!(module_id = %spec.module_id, error = %err, "registration still active after clean exit");
7207                    false
7208                }
7209            };
7210            NextAction::Stop {
7211                registration_released,
7212            }
7213        }
7214        ExitKind::Clean | ExitKind::Crash => {
7215            if unrequested_clean_exit_of_protocol_none {
7216                warn!(
7217                    module_id = %spec.module_id,
7218                    exit_code = ?exit_report.code,
7219                    exit_signal = ?exit_report.signal,
7220                    "protocol-none module exited cleanly without a stop request; handling it as a crash"
7221                );
7222            } else {
7223                warn!(
7224                    module_id = %spec.module_id,
7225                    exit_code = ?exit_report.code,
7226                    exit_signal = ?exit_report.signal,
7227                    "supervised module exited abnormally (crash)"
7228                );
7229            }
7230            let mut restart_schedule = None;
7231            let mut disposition = TerminalDisposition::Disabled;
7232            let mut final_state = ModuleState::Disabled;
7233            // Set only when the budget is what stopped the module, so the
7234            // terminal record says which limit was hit rather than leaving
7235            // `failed` to be read as "crashed once, badly".
7236            let mut disposition_detail = lock_snapshot(snapshot)
7237                .ok()
7238                .and_then(|mut state| state.spawn_failure.take());
7239            let now = Instant::now();
7240            if let Err(err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
7241                if state.enabled {
7242                    if let Some(schedule) = state.next_crash_restart(&policy, now) {
7243                        final_state = ModuleState::Restarting;
7244                        restart_schedule = Some(schedule);
7245                        disposition = TerminalDisposition::Restarting;
7246                    } else {
7247                        final_state = ModuleState::Failed;
7248                        disposition = TerminalDisposition::Failed;
7249                        let budget = policy.budget_exhausted_detail();
7250                        disposition_detail =
7251                            Some(disposition_detail.take().map_or_else(
7252                                || budget.clone(),
7253                                |cause| format!("{cause}; {budget}"),
7254                            ));
7255                    }
7256                } else {
7257                    disposition = TerminalDisposition::Disabled;
7258                }
7259            }) {
7260                error!(module_id = %spec.module_id, error = %err, "failed to record crashed module exit");
7261                return NextAction::Stop {
7262                    registration_released: false,
7263                };
7264            }
7265            if disposition == TerminalDisposition::Failed {
7266                // The window is in the message, not only in the fields: this line
7267                // is read in a scrollback where a bare `max_restarts=3` reads as a
7268                // lifetime cap and sends the operator looking for three crashes
7269                // that never happened together.
7270                error!(
7271                    module_id = %spec.module_id,
7272                    max_restarts = policy.max_restarts,
7273                    window_secs = policy.window.as_secs(),
7274                    "module stopped: {}",
7275                    policy.budget_exhausted_detail()
7276                );
7277            }
7278            if let Err(err) = publish_after_terminal_record(
7279                &spec.module_id,
7280                || {
7281                    record_terminal_with_detail(
7282                        &spec.module_id,
7283                        terminal_ring,
7284                        spawn_events,
7285                        &exit_report,
7286                        disposition,
7287                        disposition_detail,
7288                    )
7289                },
7290                || {
7291                    update_snapshot(snapshot, Some(&spec.module_id), |state| {
7292                        state.state = final_state;
7293                        clear_current_process_facts(state);
7294                        state.last_exit = Some(exit_report.clone());
7295                    })
7296                },
7297            ) {
7298                error!(module_id = %spec.module_id, error = %err, "failed to publish crashed module exit");
7299            }
7300
7301            if let Some(schedule) = restart_schedule {
7302                NextAction::Restart {
7303                    schedule: Some(schedule),
7304                }
7305            } else {
7306                let registration_released = match wait_for_registration_release(
7307                    registry,
7308                    &spec.module_id,
7309                    REGISTRY_RELEASE_TIMEOUT,
7310                )
7311                .await
7312                {
7313                    Ok(()) => true,
7314                    Err(err) => {
7315                        warn!(module_id = %spec.module_id, error = %err, "registration still active after failed module");
7316                        false
7317                    }
7318                };
7319                NextAction::Stop {
7320                    registration_released,
7321                }
7322            }
7323        }
7324        ExitKind::DeliberateSeverance => {
7325            warn!(
7326                module_id = %spec.module_id,
7327                exit_code = ?exit_report.code,
7328                exit_signal = ?exit_report.signal,
7329                "supervised module exited after deliberate connection severance"
7330            );
7331            let should_restart = match lock_snapshot(snapshot) {
7332                Ok(state) => state.enabled,
7333                Err(err) => {
7334                    error!(module_id = %spec.module_id, error = %err, "failed to read deliberately severed module state");
7335                    return NextAction::Stop {
7336                        registration_released: false,
7337                    };
7338                }
7339            };
7340            let final_state = if should_restart {
7341                ModuleState::Restarting
7342            } else {
7343                ModuleState::Disabled
7344            };
7345            if let Err(err) = publish_after_terminal_record(
7346                &spec.module_id,
7347                || {
7348                    record_terminal(
7349                        &spec.module_id,
7350                        terminal_ring,
7351                        spawn_events,
7352                        &exit_report,
7353                        terminal_disposition(final_state),
7354                    )
7355                },
7356                || {
7357                    update_snapshot(snapshot, Some(&spec.module_id), |state| {
7358                        state.state = final_state;
7359                        clear_current_process_facts(state);
7360                        state.last_exit = Some(exit_report.clone());
7361                        state.lifetime_restarts += 1;
7362                    })
7363                },
7364            ) {
7365                error!(module_id = %spec.module_id, error = %err, "failed to publish deliberately severed module exit");
7366                return NextAction::Stop {
7367                    registration_released: false,
7368                };
7369            }
7370
7371            if should_restart {
7372                NextAction::Restart { schedule: None }
7373            } else {
7374                let registration_released = match wait_for_registration_release(
7375                    registry,
7376                    &spec.module_id,
7377                    REGISTRY_RELEASE_TIMEOUT,
7378                )
7379                .await
7380                {
7381                    Ok(()) => true,
7382                    Err(err) => {
7383                        warn!(module_id = %spec.module_id, error = %err, "registration still active after deliberately severed module exit");
7384                        false
7385                    }
7386                };
7387                NextAction::Stop {
7388                    registration_released,
7389                }
7390            }
7391        }
7392    }
7393}
7394
7395async fn on_child_exit_during_daemon_shutdown(
7396    spec: &ModuleSpec,
7397    registry: &Registry,
7398    snapshot: &SharedSnapshot,
7399    terminal_ring: &Arc<Mutex<TerminalRing>>,
7400    spawn_events: &SpawnEventFeed,
7401    exit_report: ExitReport,
7402) -> NextAction {
7403    info!(
7404        module_id = %spec.module_id,
7405        exit_code = ?exit_report.code,
7406        exit_signal = ?exit_report.signal,
7407        exit_kind = ?exit_report.kind,
7408        "supervised module exited during daemon shutdown; not restarting it"
7409    );
7410    if let Err(err) = publish_after_terminal_record(
7411        &spec.module_id,
7412        || {
7413            record_terminal(
7414                &spec.module_id,
7415                terminal_ring,
7416                spawn_events,
7417                &exit_report,
7418                TerminalDisposition::DaemonShutdown,
7419            )
7420        },
7421        || {
7422            update_snapshot(snapshot, Some(&spec.module_id), |state| {
7423                state.state = ModuleState::Stopped;
7424                clear_current_process_facts(state);
7425                state.last_exit = Some(exit_report.clone());
7426            })
7427        },
7428    ) {
7429        error!(module_id = %spec.module_id, error = %err, "failed to publish module exit during daemon shutdown");
7430    }
7431    let registration_released =
7432        wait_for_registration_release(registry, &spec.module_id, REGISTRY_RELEASE_TIMEOUT)
7433            .await
7434            .is_ok();
7435    NextAction::Stop {
7436        registration_released,
7437    }
7438}
7439
7440// Keep the status lock out of terminal I/O. Readers may observe the previous
7441// state while recording is in progress, but never a completed exit without its
7442// history. Journal errors are logged by the journal and retained in the ring;
7443// a recording panic still publishes the final state before resuming the unwind.
7444fn publish_after_terminal_record<T>(
7445    module_id: &str,
7446    record: impl FnOnce(),
7447    publish: impl FnOnce() -> T,
7448) -> T {
7449    let recorded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(record));
7450    let published = publish();
7451    if let Err(panic) = recorded {
7452        error!(
7453            module_id,
7454            "terminal recording panicked; published final state before resuming unwind"
7455        );
7456        std::panic::resume_unwind(panic);
7457    }
7458    published
7459}
7460
7461fn fail_after_wait_error(
7462    module_id: &str,
7463    snapshot: &SharedSnapshot,
7464    terminal_ring: &Arc<Mutex<TerminalRing>>,
7465    spawn_events: &SpawnEventFeed,
7466    detail: String,
7467) {
7468    let report = wait_error_exit_report();
7469    publish_after_terminal_record(
7470        module_id,
7471        || {
7472            record_terminal_with_detail(
7473                module_id,
7474                terminal_ring,
7475                spawn_events,
7476                &report,
7477                TerminalDisposition::Failed,
7478                Some(detail),
7479            )
7480        },
7481        || fail_snapshot(snapshot, Some(module_id), Some(report.clone())),
7482    );
7483}
7484
7485#[cfg(test)]
7486fn record_wait_error_terminal(
7487    module_id: &str,
7488    terminal_ring: &Arc<Mutex<TerminalRing>>,
7489    spawn_events: &SpawnEventFeed,
7490) {
7491    record_terminal(
7492        module_id,
7493        terminal_ring,
7494        spawn_events,
7495        &wait_error_exit_report(),
7496        TerminalDisposition::Failed,
7497    );
7498}
7499
7500fn record_terminal(
7501    module_id: &str,
7502    terminal_ring: &Arc<Mutex<TerminalRing>>,
7503    spawn_events: &SpawnEventFeed,
7504    exit_report: &ExitReport,
7505    disposition: TerminalDisposition,
7506) {
7507    record_terminal_with_detail(
7508        module_id,
7509        terminal_ring,
7510        spawn_events,
7511        exit_report,
7512        disposition,
7513        None,
7514    );
7515}
7516
7517/// The ring lock is held only to capture the read (see
7518/// `TerminalJournal::capture_read`), so this module's exits keep recording
7519/// while the journal files are read. Blocking: it reads files.
7520fn durable_terminal_history_of(
7521    terminal_ring: &Mutex<TerminalRing>,
7522    module_id: &str,
7523) -> subc_control::TerminalHistory {
7524    let read = terminal_ring
7525        .lock()
7526        .unwrap_or_else(|p| p.into_inner())
7527        .capture_durable_history();
7528    read.read(module_id)
7529}
7530
7531fn record_terminal_with_detail(
7532    module_id: &str,
7533    terminal_ring: &Arc<Mutex<TerminalRing>>,
7534    spawn_events: &SpawnEventFeed,
7535    exit_report: &ExitReport,
7536    disposition: TerminalDisposition,
7537    disposition_detail: Option<String>,
7538) {
7539    #[cfg(test)]
7540    terminal_history_tests::before_terminal_record(spawn_events);
7541    spawn_events.emit_exited(module_id, exit_report.code, exit_report.signal);
7542    let record = TerminalRecord {
7543        exit_code: exit_report.code,
7544        exit_signal: exit_report.signal,
7545        at_ms: exit_report.at_ms,
7546        disposition,
7547        exit_kind: exit_report.kind.into(),
7548        disposition_detail,
7549    };
7550    terminal_ring
7551        .lock()
7552        .unwrap_or_else(|poisoned| poisoned.into_inner())
7553        .record_exit(module_id, record);
7554}
7555
7556fn untrack_if_registration_released(
7557    process_liveness: &SupervisorProcessLiveness,
7558    registry: &Registry,
7559    module_id: &str,
7560    snapshot: &SharedSnapshot,
7561) {
7562    match registry.get_module(module_id) {
7563        Ok(None) => process_liveness.untrack_if_current(module_id, snapshot),
7564        Ok(Some(_)) => {}
7565        Err(err) => {
7566            warn!(module_id, error = %err, "could not determine whether supervisor liveness can be untracked");
7567        }
7568    }
7569}
7570
7571/// The child's environment plan: inherit the parent's, drop ambient `CK_LOG`,
7572/// then apply the module's configured entries minus daemon-private capture keys.
7573///
7574/// Separated from `spawn_child` only so it can be asserted without spawning a
7575/// process — a duplicate of this logic in a test would pass while the real one
7576/// drifted, which is the defect class this function exists to avoid.
7577/// The subc-wire half of a spawn: `--subc <connection file>` and the launch
7578/// nonce. A `protocol: "none"` module gets neither, because it cannot use
7579/// either and the argument would stop a stock binary from starting at all.
7580/// `SUBC_MODULE_ID` is set on every path since an unread variable is inert.
7581///
7582/// The plain-spawn form, kept for the tests that assert its plan; spawns go
7583/// through [`apply_wire_spawn_args_for_role`].
7584#[cfg(test)]
7585fn apply_wire_spawn_args(
7586    command: &mut Command,
7587    spec: &ModuleSpec,
7588    connection_file_path: Option<&std::path::Path>,
7589    handle: Option<&SupervisorHandle>,
7590) -> Result<Option<NonceHandoff>, SuperviseError> {
7591    apply_wire_spawn_args_for_role(
7592        command,
7593        spec,
7594        connection_file_path,
7595        handle,
7596        SpawnRole::Plain,
7597    )
7598}
7599
7600/// The read end of a spawn's launch-nonce pipe, prepared by
7601/// [`apply_wire_spawn_args_for_role`] and installed as the child's descriptor 3
7602/// by the last pre-exec step, just before `spawn()`. Windows prepares a named
7603/// server before spawn and authenticates its client after the child exists.
7604#[cfg(unix)]
7605type NonceHandoff = subc_os::LaunchNonceHandoff;
7606#[cfg(windows)]
7607type NonceHandoff = subc_os::LaunchNoncePipeHandoff;
7608#[cfg(not(any(unix, windows)))]
7609type NonceHandoff = std::convert::Infallible;
7610
7611/// Create the launch nonce that a new module process must present when it
7612/// registers or opens a consumer connection, including a replacement process
7613/// started alongside a still-serving module.
7614///
7615/// A plain spawn replaces the module's recorded nonce. A swap candidate records
7616/// a separate candidate token so the still-serving incumbent and its consumers
7617/// keep their nonce. Both records are installed before the process exists, so
7618/// the child's initial HELLO registration cannot arrive ahead of its nonce.
7619///
7620/// On Unix the nonce is delivered only through a pipe. It is written into
7621/// a pipe whose read end the child gets as descriptor 3, named by
7622/// `SUBC_LAUNCH_NONCE_FD=3:<pipe inode>`: unlike the environment, another
7623/// process of the same user cannot read it with `ps eww`. That handoff is
7624/// returned rather than installed here, because installing it replaces
7625/// whatever the child has at descriptor 3 and so must be the last pre-exec
7626/// step. Later in `spawn_child_in_slot`, `apply_cgroup_placement` first registers
7627/// the Linux callback that writes the process into its cgroup. Only after that
7628/// does `handoff.install_last` register the descriptor-3 replacement, so it
7629/// cannot close a descriptor the cgroup callback still needs.
7630/// Windows keeps SUBC_LAUNCH_NONCE as well until every module reads the named pipe.
7631fn apply_wire_spawn_args_for_role(
7632    command: &mut Command,
7633    spec: &ModuleSpec,
7634    connection_file_path: Option<&std::path::Path>,
7635    handle: Option<&SupervisorHandle>,
7636    role: SpawnRole,
7637) -> Result<Option<NonceHandoff>, SuperviseError> {
7638    command.env(SUBC_MODULE_ID_ENV, &spec.module_id);
7639    // SUBC_LAUNCH_NONCE_FD is removed for every child, `protocol: "none"`
7640    // included: a daemon started from a module's process tree inherits it,
7641    // and passing it on would point the child at a descriptor it does not
7642    // have.
7643    command.env_remove(subc_os::LAUNCH_NONCE_FD_ENV);
7644    #[cfg(windows)]
7645    {
7646        command.env_remove(subc_os::LAUNCH_NONCE_PIPE_ENV);
7647        command.env_remove(subc_os::LAUNCH_NONCE_PIPE_FALLBACK_ENV);
7648    }
7649    // Remove inherited or configured copies too: withholding must mean absent.
7650    command.env_remove(SUBC_LAUNCH_NONCE_ENV);
7651    if spec.protocol == ModuleProtocol::None {
7652        return Ok(None);
7653    }
7654    if let Some(connection_file_path) = connection_file_path {
7655        command.arg(SUBC_ARG).arg(connection_file_path);
7656    }
7657
7658    // Every subc-wire spawn receives a fresh one-time launch nonce for consumer
7659    // route.open attestation. Reserved modules additionally use the same nonce
7660    // for HELLO id-squatting protection. A respawn rotates both records.
7661    let nonce = generate_launch_nonce()?;
7662    if let Some(handle) = handle {
7663        match role {
7664            SpawnRole::Plain => {
7665                handle.set_spawn_nonce(&spec.module_id, nonce.clone());
7666                if spec.reserved {
7667                    handle.set_reserved_nonce(&spec.module_id, nonce.clone());
7668                }
7669            }
7670            SpawnRole::SwapCandidate => handle.open_swap(&spec.module_id, nonce.clone()),
7671        }
7672    }
7673    #[cfg(unix)]
7674    let handoff = {
7675        let handoff =
7676            subc_os::LaunchNonceHandoff::new(&nonce).map_err(|source| SuperviseError::Spawn {
7677                program: spec.program.clone(),
7678                source,
7679                cgroup_path: None,
7680            })?;
7681        command.env(subc_os::LAUNCH_NONCE_FD_ENV, handoff.fd_env_value());
7682        Some(handoff)
7683    };
7684    #[cfg(windows)]
7685    let handoff = prepare_windows_nonce_pipe(command, spec, &nonce);
7686    #[cfg(not(any(unix, windows)))]
7687    let handoff = None;
7688    // Older modules still read SUBC_LAUNCH_NONCE to register. Keep that copy
7689    // until the daemon's per-spawn delivery records show that every running
7690    // Windows module has read its secret from the named pipe.
7691    #[cfg(not(unix))]
7692    command.env(SUBC_LAUNCH_NONCE_ENV, nonce);
7693    Ok(handoff)
7694}
7695
7696fn apply_child_env(command: &mut Command, spec: &ModuleSpec) {
7697    command.env_remove(CK_LOG_ENV);
7698    // The spawn role is the supervisor's to set, and only on a swap candidate
7699    // (see `apply_spawn_role`). Removing it here, rather than just not setting
7700    // it, is what makes it absent on a plain spawn: the daemon's own
7701    // environment could carry it, and so could a spec built outside daemon
7702    // config (config refuses it as an `env` key). A module reading it on a
7703    // plain restart would pick the long swap budget and leave callers waiting.
7704    command.env_remove(SUBC_SPAWN_ROLE_ENV);
7705    for (key, value) in &spec.env {
7706        // cortexkit-log currently exposes retention only as a Rust struct, not
7707        // environment names. These values are daemon-private sink metadata and
7708        // must never become a public child-process contract by being inherited.
7709        if matches!(
7710            key.as_str(),
7711            CAPTURE_MAX_FILE_MB_ENV | CAPTURE_KEEP_ENV | CAPTURE_MAX_AGE_DAYS_ENV
7712        ) || key == SUBC_SPAWN_ROLE_ENV
7713        {
7714            continue;
7715        }
7716        command.env(key, value);
7717    }
7718}
7719
7720#[cfg(windows)]
7721fn prepare_windows_nonce_pipe(
7722    command: &mut Command,
7723    spec: &ModuleSpec,
7724    nonce: &str,
7725) -> Option<NonceHandoff> {
7726    match subc_os::LaunchNoncePipeHandoff::new(nonce) {
7727        Ok(handoff) => {
7728            command.env(subc_os::LAUNCH_NONCE_PIPE_ENV, handoff.name());
7729            // Let a failed pipe read use SUBC_LAUNCH_NONCE so the process can
7730            // still register until every module reads the pipe. A reader without
7731            // this variable set to env returns the pipe error instead of using
7732            // an inherited or configured environment copy.
7733            command.env(subc_os::LAUNCH_NONCE_PIPE_FALLBACK_ENV, "env");
7734            Some(handoff)
7735        }
7736        Err(error) => {
7737            warn!(module_id = %spec.module_id, %error, "could not prepare launch nonce pipe; retaining the environment handoff");
7738            None
7739        }
7740    }
7741}
7742
7743#[cfg(windows)]
7744fn start_windows_nonce_pipe(
7745    handoff: Option<NonceHandoff>,
7746    pid: u32,
7747    spec: &ModuleSpec,
7748) -> Option<subc_os::LaunchNoncePipeDelivery> {
7749    // Close an unread pipe after REGISTRY_RELEASE_TIMEOUT (one second), the
7750    // same maximum wait used for registration during a reload. Older modules
7751    // may register using SUBC_LAUNCH_NONCE without ever connecting to the pipe.
7752    handoff.and_then(|handoff| match handoff.serve(pid, REGISTRY_RELEASE_TIMEOUT) {
7753        Ok(delivery) => Some(delivery),
7754        Err(error) => {
7755            warn!(module_id = %spec.module_id, %error, "could not serve launch nonce pipe; retaining the environment handoff");
7756            None
7757        }
7758    })
7759}
7760
7761fn observed_launch_nonce_source(snapshot: &SupervisorSnapshot) -> Option<String> {
7762    #[cfg(windows)]
7763    if snapshot.process_alive && snapshot.spawned_protocol == Some(ModuleProtocol::Subc) {
7764        return Some(
7765            if snapshot
7766                .pipe_consumed
7767                .as_ref()
7768                .is_some_and(|consumed| consumed.get().is_some())
7769            {
7770                "pipe"
7771            } else {
7772                "env"
7773            }
7774            .to_owned(),
7775        );
7776    }
7777    let _ = snapshot;
7778    None
7779}
7780
7781/// Which slot a spawn fills: the module's ordinary one, or the candidate slot
7782/// of a blue/green swap.
7783#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7784enum SpawnRole {
7785    Plain,
7786    SwapCandidate,
7787}
7788
7789/// Set the spawn role for a swap candidate. A plain spawn gets nothing here;
7790/// `apply_child_env` has already removed the variable for every spawn.
7791fn apply_spawn_role(command: &mut Command, role: SpawnRole) {
7792    if role == SpawnRole::SwapCandidate {
7793        command.env(SUBC_SPAWN_ROLE_ENV, SPAWN_ROLE_SWAP_CANDIDATE);
7794    }
7795}
7796
7797#[cfg(target_os = "linux")]
7798fn previous_instance_error(spec: &ModuleSpec, pid: u32) -> SuperviseError {
7799    SuperviseError::Spawn {
7800        program: spec.program.clone(),
7801        source: io::Error::new(io::ErrorKind::WouldBlock, format!(
7802            "waiting: previous instance (pid {pid}) is still running but hidden; it cannot be identified"
7803        )),
7804        cgroup_path: None,
7805    }
7806}
7807
7808fn spawn_child(
7809    spec: &ModuleSpec,
7810    connection_file_path: Option<&std::path::Path>,
7811    handle: Option<&SupervisorHandle>,
7812    ring: &Arc<Mutex<StderrRing>>,
7813    capture_logs_dir: Option<&std::path::Path>,
7814    roster: &ChildRoster,
7815    #[cfg(target_os = "linux")] cgroup_placement: Option<&subc_cgroup::Placement>,
7816) -> Result<SupervisedChild, SuperviseError> {
7817    spawn_child_in_slot(
7818        spec,
7819        connection_file_path,
7820        handle,
7821        ring,
7822        capture_logs_dir,
7823        roster,
7824        #[cfg(target_os = "linux")]
7825        cgroup_placement,
7826        SpawnRole::Plain,
7827        false,
7828    )
7829}
7830
7831/// Spawn one process of `spec` into a slot.
7832///
7833/// `alternate_slot` picks the process's cgroup label on Linux (see `swap::cgroup_name`).
7834/// A swap candidate needs a different cgroup from the process it is replacing,
7835/// which is still alive: in the same cgroup the two would be one kill domain,
7836/// and killing a failed candidate could take the incumbent with it.
7837///
7838/// The stderr capture file is `<module_id>.stderr.log` for every process of
7839/// the module, whichever slot it is in, because that is the one file
7840/// `ck module logs` reads. During a swap's overlap both processes append to it;
7841/// the daemon writes whole lines, so the two interleave by line, which is also
7842/// the merged view an operator wants while a swap runs.
7843#[allow(clippy::too_many_arguments)]
7844fn spawn_child_in_slot(
7845    spec: &ModuleSpec,
7846    connection_file_path: Option<&std::path::Path>,
7847    handle: Option<&SupervisorHandle>,
7848    ring: &Arc<Mutex<StderrRing>>,
7849    capture_logs_dir: Option<&std::path::Path>,
7850    roster: &ChildRoster,
7851    #[cfg(target_os = "linux")] cgroup_placement: Option<&subc_cgroup::Placement>,
7852    role: SpawnRole,
7853    alternate_slot: bool,
7854) -> Result<SupervisedChild, SuperviseError> {
7855    #[cfg(target_os = "linux")]
7856    if let Some(pid) = roster.previous_instance_pid(&spec.module_id) {
7857        return Err(previous_instance_error(spec, pid));
7858    }
7859    if roster.is_closed() {
7860        return Err(SuperviseError::Spawn {
7861            program: spec.program.clone(),
7862            source: io::Error::other("the daemon is shutting down; not starting a new process"),
7863            cgroup_path: None,
7864        });
7865    }
7866    #[cfg(target_os = "linux")]
7867    let cgroup_name = {
7868        // Slot names alone are not kill domains: a retired incumbent may still
7869        // be draining when a later enable/restart spawns into the same slot.
7870        // Decimal entropy keeps the suffix unambiguous; Placement performs
7871        // the module-id escaping and constructs the filesystem path.
7872        if cgroup_placement.is_none() {
7873            swap::cgroup_name(&spec.module_id, alternate_slot)
7874        } else {
7875            let nonce = generate_launch_nonce()?;
7876            let suffix = u128::from_str_radix(&nonce[..32], 16).expect("hex launch nonce");
7877            // Leave room for byte escaping and the suffix under NAME_MAX. The
7878            // label is only for humans; the nonce identifies the kill domain.
7879            let mut end = spec.module_id.len().min(64);
7880            while !spec.module_id.is_char_boundary(end) {
7881                end -= 1;
7882            }
7883            format!(
7884                "{}_{suffix}",
7885                swap::cgroup_name(&spec.module_id[..end], alternate_slot)
7886            )
7887        }
7888    };
7889    #[cfg(not(target_os = "linux"))]
7890    let _ = alternate_slot;
7891    #[cfg(target_os = "macos")]
7892    let (mut command, privacy_exec, exec_ack) = privacy_command(spec, roster)?;
7893    #[cfg(windows)]
7894    let image_capture = windows_spawn_capture(spec).map_err(|source| SuperviseError::Spawn {
7895        program: spec.program.clone(),
7896        source,
7897        cgroup_path: None,
7898    })?;
7899    #[cfg(windows)]
7900    let mut command = Command::new(image_capture.path());
7901    #[cfg(not(any(target_os = "macos", windows)))]
7902    let mut command = Command::new(&spec.program);
7903    command.args(&spec.args);
7904    // AMBIENT `CK_LOG` MUST NOT LEAK INTO AN OTHERWISE UNCONFIGURED MODULE — but
7905    // that is the whole of the intent, so remove that one key rather than the
7906    // environment.
7907    //
7908    // This was `env_clear()` from 0.17.41 until 0.18.3, which achieved the goal
7909    // and took the POSIX environment with it. Modules spawned that way had no
7910    // HOME, XDG_RUNTIME_DIR, TMPDIR or USER, and the consequences ran past
7911    // logging:
7912    //
7913    //   * `connection_file::discover` reads XDG_RUNTIME_DIR and HOME, so with
7914    //     both unset it fell back to the temp dir alone and `ck` could not find
7915    //     a daemon running on the same machine from inside any module's process
7916    //     tree — reporting a path the file has never lived at, which reads as
7917    //     "the daemon did not write its file".
7918    //   * `default_data_home()` with HOME and XDG_DATA_HOME both unset returns
7919    //     the RELATIVE `.local/share`, so a module deriving its own store path
7920    //     resolved it against its own CWD. That is the store-fragmentation
7921    //     defect the daemon already refuses in config (`parse_doc` rejects a
7922    //     relative `storage.data_home`) arriving by derivation instead.
7923    //   * anything a module spawns inherited it: git without ~/.gitconfig,
7924    //     cargo without CARGO_HOME, ssh, python user dirs — all degrading
7925    //     quietly rather than erroring.
7926    //
7927    // Reported by iceteaSA as #104 after deploying 0.18.2, where `ck daemon`
7928    // offered one candidate under /tmp while the file sat in /run/user/1000.
7929    //
7930    // A configured module is unaffected either way: `module_spec()` puts the
7931    // resolved CK_LOG into `spec.env`, which is applied below and therefore
7932    // wins over anything ambient.
7933    apply_child_env(&mut command, spec);
7934    apply_spawn_role(&mut command, role);
7935    let nonce_handoff =
7936        apply_wire_spawn_args_for_role(&mut command, spec, connection_file_path, handle, role)?;
7937
7938    #[cfg(target_os = "linux")]
7939    let cgroup_path = cgroup_placement
7940        .map(|placement| placement.module_path(&cgroup_name))
7941        .transpose()
7942        .map_err(|source| SuperviseError::Cgroup {
7943            module_id: spec.module_id.clone(),
7944            source,
7945        })?;
7946    #[cfg(not(target_os = "linux"))]
7947    let cgroup_path: Option<PathBuf> = None;
7948    #[cfg(target_os = "linux")]
7949    if let Some(path) = &cgroup_path {
7950        if let Err(error) = apply_cgroup_placement(&mut command, spec, path) {
7951            if let Some(placement) = cgroup_placement {
7952                remove_module_cgroup(placement, &cgroup_name);
7953            }
7954            return Err(error);
7955        }
7956    }
7957
7958    let output_sink = if let Some(logs_dir) = capture_logs_dir {
7959        let path = logs_dir.join(format!("{}.stderr.log", spec.module_id));
7960        match ChildOutputSink::open(&path, capture_retention(spec)) {
7961            Ok(sink) => sink,
7962            Err(error) => {
7963                warn!(
7964                    module_id = %spec.module_id,
7965                    path = %path.display(),
7966                    error = %error,
7967                    "could not open child output capture file; forwarding to stderr"
7968                );
7969                ChildOutputSink::Stderr
7970            }
7971        }
7972    } else {
7973        ChildOutputSink::Stderr
7974    };
7975
7976    command.stdout(Stdio::piped());
7977    command.stderr(Stdio::piped());
7978    command.kill_on_drop(true);
7979    // EACH MODULE LEADS ITS OWN PROCESS GROUP (the child calls setpgid(0, 0)
7980    // before exec). In the daemon's group, a service manager that kills the
7981    // job's process group when the daemon exits (launchd's default) killed
7982    // every module at the same moment its control connection closed, so no
7983    // module ever ran its EOF teardown on a daemon stop. Outside that group a
7984    // module is reached only by the daemon: the EOF it sees when its
7985    // connection closes, and the bounded stop in `child_roster` for anything
7986    // still running after that. On Linux this composes with the cgroup
7987    // placement above: that is a pre_exec write to cgroup.procs, std performs
7988    // setpgid in the child before running pre_exec callbacks, and the two
7989    // change independent process attributes.
7990    //
7991    // stdin is /dev/null because a process outside the terminal's foreground
7992    // group is stopped (SIGTTIN) if it reads the terminal, which a daemon run
7993    // by hand would otherwise hand down. Under a service manager stdin is
7994    // already /dev/null.
7995    #[cfg(unix)]
7996    command.process_group(0);
7997    command.stdin(Stdio::null());
7998    // The LAST pre-exec step, after the cgroup placement above: installing the
7999    // nonce at descriptor 3 replaces whatever the child had there, which could
8000    // be the descriptor an earlier step writes through.
8001    #[cfg(unix)]
8002    if let Some(handoff) = nonce_handoff {
8003        handoff.install_last(command.as_std_mut());
8004    }
8005    #[cfg(not(any(unix, windows)))]
8006    let _ = nonce_handoff;
8007
8008    // Containment, step 1 of 3 (issue #109): create the child suspended so it
8009    // cannot run a single instruction -- and therefore cannot spawn a
8010    // grandchild -- before it is in the job. See `contain_spawned_child` for the
8011    // other two steps and why the window matters.
8012    #[cfg(windows)]
8013    subc_jobobject::suspend_on_create_async(&mut command);
8014    let mut child = match command.spawn() {
8015        Ok(child) => child,
8016        Err(source) => {
8017            #[cfg(target_os = "linux")]
8018            if let Some(placement) = cgroup_placement {
8019                remove_module_cgroup(placement, &cgroup_name);
8020            }
8021            return Err(SuperviseError::Spawn {
8022                program: spec.program.clone(),
8023                source,
8024                cgroup_path,
8025            });
8026        }
8027    };
8028    // The parent must close its writer now: the acknowledgement pipe reports EOF
8029    // only when every writer is gone, and the child's copy closes when the
8030    // trampoline replaces itself with the module. Command holds only an integer
8031    // in its pre_exec callback, not another writer.
8032    #[cfg(target_os = "macos")]
8033    drop(exec_ack);
8034
8035    // Bind while the child is still suspended, before job assignment resumes it.
8036    // Keeping the executable handle open across CreateProcessW denies rename
8037    // and deletion, so the file cannot be exchanged during spawn. Bind that
8038    // file ID to the process handle returned by spawn, never a reopened PID.
8039    #[cfg(windows)]
8040    let windows_image =
8041        Arc::new(
8042            image_capture
8043                .bind_tokio(&child)
8044                .map_err(|source| SuperviseError::Spawn {
8045                    program: spec.program.clone(),
8046                    source,
8047                    cgroup_path: None,
8048                })?,
8049        );
8050    // Containment, steps 2 and 3: assign while suspended, then resume.
8051    #[cfg(windows)]
8052    let job = contain_spawned_child(&child, spec)?;
8053    let spawned_at_ms = unix_ms_now();
8054    #[cfg(not(windows))]
8055    let spawned_from = spec.program.clone();
8056    #[cfg(windows)]
8057    let spawned_from = windows_image.path().to_path_buf();
8058    #[cfg(not(windows))]
8059    let spawned_file_identity = spawned_file_identity(&spawned_from);
8060    #[cfg(windows)]
8061    let spawned_file_identity = Some(SpawnedFileIdentity {
8062        device: 0,
8063        inode: 0,
8064        windows_image: Some(Arc::clone(&windows_image)),
8065    });
8066    let pid = child.id().ok_or_else(|| SuperviseError::Spawn {
8067        program: spec.program.clone(),
8068        source: io::Error::other("spawned child exposed no live pid"),
8069        cgroup_path: cgroup_path.clone(),
8070    })?;
8071    #[cfg(windows)]
8072    let nonce_delivery = start_windows_nonce_pipe(nonce_handoff, pid, spec);
8073    #[cfg(not(windows))]
8074    let process_start_time = crate::provenance::process_start_time(pid);
8075    #[cfg(windows)]
8076    let process_start_time = Some(windows_image.start_time());
8077    let process_identity = process_start_time.map(|start_time| ProcessIdentity { pid, start_time });
8078    #[cfg(all(test, target_os = "macos"))]
8079    privacy_exec_boundary_tests::before_image_sample(spec, pid);
8080    // Unix spawn returns after exec's error pipe closes. The kernel image is
8081    // therefore the executable to compare during a future orphan sweep: PATH
8082    // lookup and shebang interpretation may select a different file from the
8083    // configured program. Keep the literal program's identity for provenance,
8084    // but never use it as proof that a recorded pid may be signalled.
8085    #[cfg(not(windows))]
8086    let recorded_image = observe_spawned_image(pid);
8087    // spawn() confirms only the first exec, into the trampoline. Never persist
8088    // the trampoline image; the asynchronous acknowledgement publishes the
8089    // module image once the trampoline has replaced itself with the module.
8090    #[cfg(target_os = "macos")]
8091    let recorded_image = if privacy_exec.is_some() {
8092        None
8093    } else {
8094        recorded_image
8095    };
8096    #[cfg(target_os = "linux")]
8097    let recorded_cgroup_name = cgroup_path.as_ref().map(|_| cgroup_name.clone());
8098    #[cfg(not(target_os = "linux"))]
8099    let recorded_cgroup_name = None;
8100    let roster_guard = roster.admit(
8101        spec.module_id.clone(),
8102        pid,
8103        spec.protocol,
8104        process_start_time,
8105        crate::child_roster::RecordedIdentity {
8106            #[cfg(not(windows))]
8107            start_time: recorded_image.map(|image| image.start_time),
8108            #[cfg(windows)]
8109            start_time: process_start_time,
8110            #[cfg(not(windows))]
8111            executable: recorded_image
8112                .and_then(|image| image.executable)
8113                .map(crate::live_children::ExecutableIdentity::from),
8114            #[cfg(windows)]
8115            executable: Some(crate::live_children::ExecutableIdentity::from(
8116                windows_image.identity(),
8117            )),
8118            cgroup_name: recorded_cgroup_name,
8119            #[cfg(target_os = "linux")]
8120            cgroup_placement: cgroup_placement.cloned(),
8121        },
8122    );
8123    // The check at the top of this function can pass just before daemon
8124    // shutdown begins, and the process is only in the roster from here on.
8125    // The shutdown stop returns as soon as it finds the roster empty, so a
8126    // process admitted after that look would outlive the daemon. The roster
8127    // is closed before the stop first reads it and admission happens under
8128    // the roster's lock, so either the stop sees this process or this check
8129    // sees the roster closed: end the process now rather than start a module
8130    // the daemon is about to stop.
8131    if roster.is_closed() {
8132        // This child was never admitted, so there is no module protocol shutdown to wait for.
8133        #[cfg(target_os = "linux")]
8134        kill_module_cgroup(cgroup_placement, &cgroup_name);
8135        if let Err(error) = child.start_kill() {
8136            debug!(module_id = %spec.module_id, pid, %error, "kill of a process spawned during daemon shutdown failed; it may already have exited");
8137        }
8138        #[cfg(target_os = "linux")]
8139        if let Some(placement) = cgroup_placement {
8140            // This spawn was never admitted, so shutdown has no roster entry
8141            // to await. Do not detach its cleanup: the runtime could exit
8142            // before that task reaps the rejected child and removes its group.
8143            while matches!(child.try_wait(), Ok(None)) {
8144                std::thread::yield_now();
8145            }
8146            if matches!(
8147                subc_cgroup::kill_module(Some(placement), &cgroup_name),
8148                subc_cgroup::KillOutcome::Killed
8149            ) {
8150                if let Ok(path) = placement.module_path(&cgroup_name) {
8151                    while std::fs::read_to_string(path.join("cgroup.events"))
8152                        .ok()
8153                        .is_some_and(|events| events.lines().any(|line| line == "populated 1"))
8154                    {
8155                        std::thread::yield_now();
8156                    }
8157                }
8158            }
8159            remove_module_cgroup(placement, &cgroup_name);
8160        }
8161        drop(roster_guard);
8162        return Err(SuperviseError::Spawn {
8163            program: spec.program.clone(),
8164            source: io::Error::other(
8165                "the daemon began shutting down while this process was starting; ended it",
8166            ),
8167            cgroup_path,
8168        });
8169    }
8170
8171    let stdout_pump = match child.stdout.take() {
8172        Some(stdout) => Some(tokio::spawn(pump_stdout_to(stdout, output_sink.clone()))),
8173        None => {
8174            warn!(
8175                module_id = %spec.module_id,
8176                "spawned child exposed no stdout pipe; file capture will be incomplete"
8177            );
8178            None
8179        }
8180    };
8181    let stderr_pump = match child.stderr.take() {
8182        Some(stderr) => {
8183            let generation = ring
8184                .lock()
8185                .unwrap_or_else(|poisoned| poisoned.into_inner())
8186                .begin_process();
8187            Some(StderrPump {
8188                task: tokio::spawn(pump_stderr_to(
8189                    stderr,
8190                    Arc::clone(ring),
8191                    generation,
8192                    output_sink,
8193                )),
8194                generation,
8195            })
8196        }
8197        None => {
8198            // Spawning succeeded but the pipe did not materialise. Recording it as
8199            // uncaptured keeps the tail honest: the alternative is an empty tail
8200            // that reads as a module which printed nothing.
8201            ring.lock()
8202                .unwrap_or_else(|poisoned| poisoned.into_inner())
8203                .mark_not_captured("stderr pipe was not available on spawn");
8204            warn!(
8205                module_id = %spec.module_id,
8206                "spawned child exposed no stderr pipe; tail will be unavailable"
8207            );
8208            None
8209        }
8210    };
8211
8212    Ok(SupervisedChild {
8213        child,
8214        #[cfg(windows)]
8215        nonce_delivery,
8216        protocol: spec.protocol,
8217        #[cfg(target_os = "linux")]
8218        module_id: cgroup_name,
8219        #[cfg(target_os = "linux")]
8220        cgroup_placement: cgroup_placement.cloned(),
8221        #[cfg(windows)]
8222        job,
8223        stdout_pump,
8224        stderr_pump,
8225        stderr_ring: Arc::clone(ring),
8226        spawned_at_ms,
8227        spawned_from,
8228        spawned_file_identity,
8229        process_start_time,
8230        process_identity,
8231        pid,
8232        roster_guard: Some(roster_guard),
8233        #[cfg(target_os = "macos")]
8234        privacy_exec,
8235        #[cfg(target_os = "macos")]
8236        report_ready: Arc::new(OnceLock::new()),
8237        spawn_failure: None,
8238    })
8239}
8240
8241#[cfg(target_os = "linux")]
8242pub(crate) fn kill_module_cgroup(placement: Option<&subc_cgroup::Placement>, module_id: &str) {
8243    use subc_cgroup::KillOutcome;
8244    match subc_cgroup::kill_module(placement, module_id) {
8245        KillOutcome::Killed => {}
8246        KillOutcome::NotPlaced | KillOutcome::Unsupported => {
8247            debug!(
8248                module_id,
8249                "cgroup tree kill unavailable; using direct-child kill"
8250            );
8251        }
8252        KillOutcome::IoError { path, error } => {
8253            warn!(module_id, path = %path.display(), %error, "cgroup tree kill failed; using direct-child kill");
8254        }
8255    }
8256}
8257
8258/// Contain a freshly spawned Windows child and start it.
8259///
8260/// Steps 2 and 3 of the suspended-create contract: the job is created and the
8261/// child assigned **while it is still suspended** (step 1 is
8262/// `suspend_on_create_async` at the spawn site), then the child is resumed.
8263///
8264/// A child that is never resumed hangs forever holding a pid, so a resume
8265/// failure kills the child and fails the spawn rather than returning a
8266/// `SupervisedChild` that can never run.
8267///
8268/// An assignment failure is NOT fatal: an uncontained module behaves exactly as
8269/// it did before this existed, whereas refusing to start one would be a new
8270/// outage. It is logged at warn because it means a helper process could leak.
8271#[cfg(windows)]
8272fn contain_spawned_child(
8273    child: &Child,
8274    spec: &ModuleSpec,
8275) -> Result<Option<subc_jobobject::JobObject>, SuperviseError> {
8276    let module_id = spec.module_id.as_str();
8277    let Some(pid) = child.id() else {
8278        // The child exited between spawn and here. Its tree, if it made one,
8279        // needs no containment: nothing is left to contain.
8280        warn!(
8281            module_id,
8282            "spawned child had already exited before containment; no job object attached"
8283        );
8284        return Ok(None);
8285    };
8286
8287    let job = match subc_jobobject::JobObject::new() {
8288        Ok(job) => job,
8289        Err(source) => {
8290            warn!(
8291                module_id,
8292                error = %source,
8293                "could not create a job object; this module's helper processes will not be \
8294                 reaped on teardown"
8295            );
8296            // Resume regardless: leaving the child suspended would turn a
8297            // containment gap into a hung module.
8298            resume_suspended_child(pid, spec)?;
8299            return Ok(None);
8300        }
8301    };
8302
8303    if let Err(source) = job.assign(child) {
8304        warn!(
8305            module_id,
8306            error = %source,
8307            "could not assign the child to its job object; this module's helper processes \
8308             will not be reaped on teardown"
8309        );
8310        resume_suspended_child(pid, spec)?;
8311        return Ok(None);
8312    }
8313
8314    resume_suspended_child(pid, spec)?;
8315    Ok(Some(job))
8316}
8317
8318/// Resume a suspended child, killing it if it cannot be started.
8319///
8320/// A suspended process holds a pid and does nothing, so there is no useful
8321/// state to return: the caller gets an error and the spawn fails.
8322#[cfg(windows)]
8323fn resume_suspended_child(pid: u32, spec: &ModuleSpec) -> Result<(), SuperviseError> {
8324    if let Err(source) = subc_jobobject::resume_main_thread(pid) {
8325        // Kill it here rather than leaving a suspended process for the caller
8326        // to notice; `kill_on_drop` would eventually do this, but the module
8327        // would have been reported as running in between.
8328        let _ = std::process::Command::new("taskkill.exe")
8329            .args(["/PID", &pid.to_string(), "/T", "/F"])
8330            .stdin(Stdio::null())
8331            .stdout(Stdio::null())
8332            .stderr(Stdio::null())
8333            .status();
8334        return Err(SuperviseError::Spawn {
8335            program: spec.program.clone(),
8336            source,
8337            cgroup_path: None,
8338        });
8339    }
8340    Ok(())
8341}
8342
8343#[cfg(target_os = "linux")]
8344fn remove_module_cgroup(placement: &subc_cgroup::Placement, module_id: &str) {
8345    match placement.remove_module(module_id) {
8346        Ok(()) => debug!(module_id, "removed module cgroup after process exit"),
8347        Err(error) => warn!(
8348            module_id,
8349            error = %error,
8350            "could not remove module cgroup after process exit; continuing teardown"
8351        ),
8352    }
8353}
8354
8355#[cfg(target_os = "linux")]
8356async fn cleanup_reaped_cgroup(placement: &subc_cgroup::Placement, module_id: &str) {
8357    // Reaping the direct child is not proof its descendants exited. End the
8358    // residual tree and wait for the kernel's population fact before rmdir;
8359    // otherwise a successful parent wait leaks a directory on each restart.
8360    if matches!(
8361        subc_cgroup::kill_module(Some(placement), module_id),
8362        subc_cgroup::KillOutcome::Killed
8363    ) {
8364        if let Ok(path) = placement.module_path(module_id) {
8365            while std::fs::read_to_string(path.join("cgroup.events"))
8366                .ok()
8367                .is_some_and(|events| events.lines().any(|line| line == "populated 1"))
8368            {
8369                sleep(Duration::from_millis(1)).await;
8370            }
8371        }
8372    }
8373    remove_module_cgroup(placement, module_id);
8374}
8375
8376#[cfg(target_os = "linux")]
8377fn apply_cgroup_placement(
8378    command: &mut Command,
8379    spec: &ModuleSpec,
8380    path: &std::path::Path,
8381) -> Result<(), SuperviseError> {
8382    subc_cgroup::apply(command, path).map_err(|source| SuperviseError::Cgroup {
8383        module_id: spec.module_id.clone(),
8384        source,
8385    })
8386}
8387
8388fn capture_retention(spec: &ModuleSpec) -> Retention {
8389    let defaults = Retention::default();
8390    let value = |name: &str| {
8391        spec.env
8392            .iter()
8393            .rev()
8394            .find_map(|(key, value)| (key == name).then_some(value.as_str()))
8395    };
8396    Retention {
8397        max_file_mb: value(CAPTURE_MAX_FILE_MB_ENV)
8398            .and_then(|value| value.parse().ok())
8399            .unwrap_or(defaults.max_file_mb),
8400        keep: value(CAPTURE_KEEP_ENV)
8401            .and_then(|value| value.parse().ok())
8402            .unwrap_or(defaults.keep),
8403        max_age_days: value(CAPTURE_MAX_AGE_DAYS_ENV)
8404            .and_then(|value| value.parse().ok())
8405            .unwrap_or(defaults.max_age_days),
8406    }
8407}
8408
8409/// A fresh 256-bit CSPRNG launch nonce, lowercase hex. Used to bind a reserved
8410/// module's registration to the exact process the supervisor spawned.
8411fn generate_launch_nonce() -> Result<String, SuperviseError> {
8412    let mut bytes = [0u8; 32];
8413    getrandom::getrandom(&mut bytes).map_err(|source| SuperviseError::LaunchNonce {
8414        reason: source.to_string(),
8415    })?;
8416    let mut hex = String::with_capacity(64);
8417    for b in bytes {
8418        use std::fmt::Write;
8419        let _ = write!(hex, "{b:02x}");
8420    }
8421    Ok(hex)
8422}
8423
8424/// Constant-time byte comparison so a reserved-nonce mismatch leaks no timing
8425/// signal about how many leading bytes matched.
8426fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
8427    if a.len() != b.len() {
8428        return false;
8429    }
8430    let mut diff = 0u8;
8431    for (x, y) in a.iter().zip(b.iter()) {
8432        diff |= x ^ y;
8433    }
8434    diff == 0
8435}
8436
8437/// The kernel's image after an acknowledged exec, shared by ordinary launches
8438/// and privacy trampolines. PATH and shebang interpretation are kernel facts,
8439/// not identities inferred from a configured pathname.
8440#[cfg(not(windows))]
8441fn observe_spawned_image(pid: u32) -> Option<subc_os::Observation> {
8442    subc_os::Process::open(pid)
8443        .ok()
8444        .flatten()
8445        .and_then(|process| process.observe())
8446}
8447
8448/// Resolve once and spawn that exact path, so PATH lookup cannot choose a
8449/// different executable after the no-delete capture was taken.
8450#[cfg(windows)]
8451fn windows_spawn_capture(spec: &ModuleSpec) -> io::Result<subc_os::ExecutableCapture> {
8452    let mut program = spec.program.clone();
8453    if program.is_absolute() || program.components().count() > 1 {
8454        // An explicit path can name a PE image with no extension. Try that
8455        // exact file first, as Command does, before falling back to .exe.
8456        match subc_os::ExecutableCapture::open(&program) {
8457            Ok(capture) => return Ok(capture),
8458            Err(error)
8459                if error.kind() == io::ErrorKind::NotFound && program.extension().is_none() => {}
8460            Err(error) => return Err(error),
8461        }
8462        program.set_extension("exe");
8463        return subc_os::ExecutableCapture::open(&program);
8464    }
8465    if program.extension().is_none() {
8466        program.set_extension("exe");
8467    }
8468    let custom_path = spec
8469        .env
8470        .iter()
8471        .rev()
8472        .find(|(key, _)| key.eq_ignore_ascii_case("PATH"));
8473    let mut directories = Vec::new();
8474    // These are Windows' default executable search locations. A configured
8475    // PATH still overrides the inherited PATH, not the system directories.
8476    if let Some(parent) = std::env::current_exe()?.parent() {
8477        directories.push(parent.to_path_buf());
8478    }
8479    directories.push(std::env::current_dir()?);
8480    if let Some(windows) = std::env::var_os("SystemRoot") {
8481        let windows = PathBuf::from(windows);
8482        directories.push(windows.join("System32"));
8483        directories.push(windows.clone());
8484    }
8485    let path = custom_path
8486        .map(|(_, value)| std::ffi::OsString::from(value))
8487        .or_else(|| std::env::var_os("PATH"));
8488    if let Some(path) = path {
8489        directories.extend(std::env::split_paths(&path));
8490    }
8491    for directory in directories {
8492        match subc_os::ExecutableCapture::open(&directory.join(&program)) {
8493            Ok(capture) => return Ok(capture),
8494            Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
8495            Err(error) => return Err(error),
8496        }
8497    }
8498    Err(io::Error::new(
8499        io::ErrorKind::NotFound,
8500        "resolved executable not found",
8501    ))
8502}
8503
8504fn spawn_and_mark_running(
8505    spec: &ModuleSpec,
8506    runtime: &SupervisorRuntimeConfig,
8507    snapshot: &SharedSnapshot,
8508) -> Result<SupervisedChild, SuperviseError> {
8509    let child = spawn_child(
8510        spec,
8511        runtime.connection_file_path.as_deref(),
8512        runtime.supervisor_handle.as_ref(),
8513        &runtime.stderr_ring,
8514        runtime.capture_logs_dir.as_deref(),
8515        &runtime.child_roster,
8516        #[cfg(target_os = "linux")]
8517        runtime.cgroup_placement.as_ref(),
8518    )?;
8519    set_running(snapshot, &child, &spec.module_id, &runtime.spawn_events)?;
8520    Ok(child)
8521}
8522
8523enum RegistrationWaitOutcome {
8524    Registered,
8525    Exited(ExitReport),
8526    TimedOut,
8527}
8528
8529struct ReloadRegistrationFailure {
8530    exit_report: ExitReport,
8531    reason: String,
8532}
8533
8534#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8535enum BusyGaugeObservation {
8536    Quiescent,
8537    Busy,
8538    Omitted,
8539}
8540
8541fn busy_gauge_observation(metrics: Option<&Value>, gauges: &[String]) -> BusyGaugeObservation {
8542    let Some(metrics) = metrics.and_then(Value::as_object) else {
8543        return BusyGaugeObservation::Omitted;
8544    };
8545    let mut sum = 0u128;
8546    for gauge in gauges {
8547        let Some(value) = metrics.get(gauge) else {
8548            return BusyGaugeObservation::Omitted;
8549        };
8550        let Some(value) = value.as_u64() else {
8551            return BusyGaugeObservation::Busy;
8552        };
8553        sum = sum.saturating_add(u128::from(value));
8554    }
8555    if sum == 0 {
8556        BusyGaugeObservation::Quiescent
8557    } else {
8558        BusyGaugeObservation::Busy
8559    }
8560}
8561
8562fn declared_busy_gauges(
8563    registry: &Registry,
8564    module_id: &str,
8565) -> Result<Vec<String>, SuperviseError> {
8566    busy_gauges_of(
8567        registry
8568            .get_module(module_id)
8569            .map_err(SuperviseError::Registry)?,
8570    )
8571}
8572
8573/// [`declared_busy_gauges`] for the registration a connection holds, in any
8574/// slot: after cutover the incumbent is no longer the id's active
8575/// registration, and its own manifest is the one that names its gauges.
8576fn declared_busy_gauges_for_connection(
8577    registry: &Registry,
8578    connection_id: ConnectionId,
8579) -> Result<Vec<String>, SuperviseError> {
8580    busy_gauges_of(
8581        registry
8582            .get_module_by_connection(connection_id)
8583            .map_err(SuperviseError::Registry)?,
8584    )
8585}
8586
8587fn busy_gauges_of(
8588    registration: Option<crate::registry::ModuleRegistration>,
8589) -> Result<Vec<String>, SuperviseError> {
8590    let Some(registration) = registration else {
8591        return Ok(Vec::new());
8592    };
8593    let Some(self_signals) = registration.manifest.self_signals else {
8594        return Ok(Vec::new());
8595    };
8596
8597    let mut gauges = Vec::new();
8598    for declaration in self_signals {
8599        if declaration.kind != SelfSignalKind::Busy {
8600            continue;
8601        }
8602        match declaration.anchored_to {
8603            SignalAnchor::HealthGauges { gauges: declared } if !declared.is_empty() => {
8604                gauges.extend(declared)
8605            }
8606            _ => {
8607                // An invalid Busy anchor is fail-safe: the empty name cannot be
8608                // present in a conforming health report, so this drain stays busy.
8609                gauges.push(String::new());
8610            }
8611        }
8612    }
8613    Ok(gauges)
8614}
8615
8616/// Wait for `endpoint` to have nothing in flight and, when the module declares
8617/// busy gauges, for a health probe to report them quiet. The probe is addressed
8618/// by `scope`: a swap's superseded incumbent must be asked about its own
8619/// gauges, and by module id the probe would reach the promoted candidate.
8620async fn wait_for_forwarding_quiescence(
8621    forwarding: &ForwardingTable,
8622    module_id: &str,
8623    runtime: &SupervisorRuntimeConfig,
8624    endpoint: crate::ModuleEndpointId,
8625    deadline: Instant,
8626    busy_gauges: &[String],
8627    scope: DrainScope,
8628) -> Result<bool, SuperviseError> {
8629    let mut gauges_quiescent = busy_gauges.is_empty();
8630    let mut next_probe_at = Instant::now();
8631    let mut omission_counted = false;
8632
8633    loop {
8634        let now = Instant::now();
8635        if !busy_gauges.is_empty() && now >= next_probe_at && now < deadline {
8636            let report = match scope {
8637                DrainScope::Active => probe_module_health(module_id, runtime, Some(deadline)).await,
8638                DrainScope::Endpoint(endpoint) => {
8639                    probe_endpoint_health(endpoint, runtime, Some(deadline)).await
8640                }
8641            };
8642            gauges_quiescent = match report {
8643                Ok(report) => match busy_gauge_observation(report.metrics.as_ref(), busy_gauges) {
8644                    BusyGaugeObservation::Quiescent => true,
8645                    BusyGaugeObservation::Busy => false,
8646                    BusyGaugeObservation::Omitted => {
8647                        if !omission_counted {
8648                            forwarding
8649                                .counters()
8650                                .increment_drains_with_undeclared_gauge();
8651                            omission_counted = true;
8652                        }
8653                        false
8654                    }
8655                },
8656                Err(err) => {
8657                    warn!(
8658                        module_id,
8659                        error = %err,
8660                        "drain health.check did not produce declared busy gauges; treating module as busy"
8661                    );
8662                    false
8663                }
8664            };
8665            next_probe_at = Instant::now() + runtime.health.cadence.max(REGISTRY_RELEASE_POLL);
8666        }
8667
8668        let in_flight = forwarding
8669            .endpoint_in_flight_count(endpoint)
8670            .map_err(SuperviseError::Forwarding)?;
8671        if in_flight == 0 && gauges_quiescent {
8672            return Ok(true);
8673        }
8674
8675        let now = Instant::now();
8676        if now >= deadline {
8677            return Ok(false);
8678        }
8679        let mut wait = deadline
8680            .saturating_duration_since(now)
8681            .min(REGISTRY_RELEASE_POLL);
8682        if !busy_gauges.is_empty() {
8683            wait = wait.min(next_probe_at.saturating_duration_since(now));
8684        }
8685        sleep(wait).await;
8686    }
8687}
8688
8689/// The `route.closed` `drained` value implied by a quiescence-wait outcome.
8690///
8691/// `Ok` is always honest and passed straight through -- the wait actually measured
8692/// in-flight state. `Err` means the wait produced no measurement at all (the
8693/// forwarding table's lock was poisoned), so `false` is reported as the one honest
8694/// constant: the drain did not complete. Never recomputed from route state, never a
8695/// third "unknown" value -- the caller must still send a well-formed `route.closed`.
8696fn drained_after_quiescence_wait(wait_result: &Result<bool, SuperviseError>) -> bool {
8697    match wait_result {
8698        Ok(drained) => *drained,
8699        Err(_) => false,
8700    }
8701}
8702
8703fn send_route_goodbyes(forwarding: &ForwardingTable, released_routes: Vec<GoodbyeTarget>) {
8704    for released in released_routes {
8705        let frame = match Frame::build_with_version(
8706            released.negotiated_ver,
8707            FrameType::Goodbye,
8708            control_flags(),
8709            released.channel,
8710            released.epoch,
8711            0,
8712            Vec::new(),
8713        ) {
8714            Ok(frame) => frame,
8715            Err(err) => {
8716                warn!(
8717                    route_channel = released.channel,
8718                    error = %err,
8719                    "failed to build supervisor drain route GOODBYE frame"
8720                );
8721                continue;
8722            }
8723        };
8724        if !released.close_on_delivery_failure() {
8725            crate::forwarding::send_module_route_goodbye(
8726                &forwarding.counters(),
8727                &released.sink,
8728                frame,
8729                released.module_id.as_deref(),
8730                "supervisor drain",
8731            );
8732            continue;
8733        }
8734        if let Err(err) = released.sink.try_send(frame) {
8735            warn!(
8736                target_connection_id = released.connection_id.get(),
8737                route_channel = released.channel,
8738                error = %err,
8739                "supervisor drain route GOODBYE was not delivered to client; closing target connection"
8740            );
8741            let _ = forwarding.escalate_client_delivery_failure(
8742                released.connection_id,
8743                released.channel,
8744                released.epoch,
8745                CloseReason::new(
8746                    "route_goodbye_delivery_failed",
8747                    format!(
8748                        "failed to enqueue supervisor drain route GOODBYE for channel {}: {err}",
8749                        released.channel
8750                    ),
8751                ),
8752                crate::forwarding::UndeliveredFrame {
8753                    module_id: released.module_id.as_deref(),
8754                    sink: &released.sink,
8755                },
8756            );
8757        }
8758    }
8759}
8760
8761fn send_module_draining(
8762    module_id: &str,
8763    reason: RouteCloseReason,
8764    deadline_ms: u64,
8765    target: &ModuleDrainTarget,
8766) {
8767    let body = match serde_json::to_vec(&ModuleControlCommand::Draining {
8768        reason,
8769        deadline_ms,
8770    }) {
8771        Ok(body) => body,
8772        Err(err) => {
8773            warn!(
8774                module_id,
8775                error = %err,
8776                "failed to encode module draining command"
8777            );
8778            return;
8779        }
8780    };
8781    let frame = match Frame::build_with_version(
8782        target.negotiated_ver,
8783        FrameType::Push,
8784        control_flags(),
8785        0,
8786        0,
8787        0,
8788        body,
8789    ) {
8790        Ok(frame) => frame,
8791        Err(err) => {
8792            warn!(
8793                module_id,
8794                error = %err,
8795                "failed to build module draining command frame"
8796            );
8797            return;
8798        }
8799    };
8800    if let Err(err) = target.sink.try_send(frame) {
8801        warn!(
8802            module_id,
8803            target_connection_id = target.endpoint.connection_id.get(),
8804            error = %err,
8805            "module draining command was not delivered to peer"
8806        );
8807    }
8808}
8809
8810/// The channel-0 GOODBYE that tells a module its stop is planned.
8811fn module_goodbye_frame(module_id: &str, negotiated_ver: u8) -> Option<Frame> {
8812    match Frame::build_with_version(
8813        negotiated_ver,
8814        FrameType::Goodbye,
8815        control_flags(),
8816        0,
8817        0,
8818        0,
8819        Vec::new(),
8820    ) {
8821        Ok(frame) => Some(frame),
8822        Err(err) => {
8823            warn!(
8824                module_id,
8825                error = %err,
8826                "failed to build module GOODBYE frame"
8827            );
8828            None
8829        }
8830    }
8831}
8832
8833/// Send every registered module connection its module GOODBYE at daemon
8834/// shutdown, then request that connection's close.
8835///
8836/// A module tells a planned stop from a lost daemon by whether a GOODBYE came
8837/// before EOF, so the GOODBYE must reach the socket before the close. A close
8838/// request does not wait for the connection's queued frames: its writer gets a
8839/// bounded grace after the close, is aborted if it overruns it, and the daemon
8840/// process may exit before that grace ends. So with `wait_for_flush`, each
8841/// connection is closed only after its writer has acknowledged writing the
8842/// GOODBYE, or once a short shared budget runs out, so one module that is not
8843/// reading cannot hold up the others or the shutdown. Without it the GOODBYEs
8844/// are only queued, for a shutdown the operator has told to stop waiting.
8845/// A connection that is already gone is skipped.
8846#[cfg(unix)]
8847async fn send_module_goodbyes_for_daemon_shutdown(
8848    forwarding: &Arc<ForwardingTable>,
8849    reason: &CloseReason,
8850    wait_for_flush: bool,
8851) {
8852    const GOODBYE_BUDGET: Duration = Duration::from_millis(500);
8853    let targets = match forwarding.module_connections() {
8854        Ok(targets) => targets,
8855        Err(err) => {
8856            warn!(error = %err, "could not list module connections for shutdown GOODBYE");
8857            return;
8858        }
8859    };
8860    let deadline = Instant::now() + GOODBYE_BUDGET;
8861    let mut sends = tokio::task::JoinSet::new();
8862    for target in targets {
8863        let Some(frame) = module_goodbye_frame(&target.module_id, target.negotiated_ver) else {
8864            continue;
8865        };
8866        if !wait_for_flush {
8867            if let Err(err) = target.sink.try_send(frame) {
8868                debug!(
8869                    module_id = %target.module_id,
8870                    error = %err,
8871                    "shutdown module GOODBYE was not queued"
8872                );
8873            }
8874            continue;
8875        }
8876        let forwarding = Arc::clone(forwarding);
8877        let reason = reason.clone();
8878        sends.spawn(async move {
8879            match timeout_at(deadline, target.sink.send_flushed(frame)).await {
8880                Ok(Ok(())) => {}
8881                Ok(Err(err)) => debug!(
8882                    module_id = %target.module_id,
8883                    error = %err,
8884                    "module connection closed before its shutdown GOODBYE was written"
8885                ),
8886                Err(_) => warn!(
8887                    module_id = %target.module_id,
8888                    budget = ?GOODBYE_BUDGET,
8889                    "shutdown module GOODBYE was not written within its budget; closing anyway"
8890                ),
8891            }
8892            forwarding.request_connection_close(target.endpoint.connection_id, reason);
8893        });
8894    }
8895    // Every task ends by the shared deadline, so this wait is bounded too.
8896    while sends.join_next().await.is_some() {}
8897}
8898
8899fn send_module_goodbye(module_id: &str, forwarding: &ForwardingTable, target: &ModuleDrainTarget) {
8900    let Some(frame) = module_goodbye_frame(module_id, target.negotiated_ver) else {
8901        return;
8902    };
8903    if let Err(err) = target.sink.try_send(frame) {
8904        warn!(
8905            module_id,
8906            target_connection_id = target.endpoint.connection_id.get(),
8907            error = %err,
8908            "supervisor drain module GOODBYE was not delivered to peer; closing module connection"
8909        );
8910        forwarding.request_connection_close(
8911            target.endpoint.connection_id,
8912            CloseReason::new(
8913                "module_goodbye_delivery_failed",
8914                format!("failed to enqueue supervisor drain module GOODBYE for module '{module_id}': {err}"),
8915            ),
8916        );
8917    }
8918}
8919
8920#[derive(Clone, Copy)]
8921struct ForwardingDrainContext<'a> {
8922    spec: &'a ModuleSpec,
8923    runtime: &'a SupervisorRuntimeConfig,
8924    registry: &'a Registry,
8925    scope: DrainScope,
8926}
8927
8928/// Which process a forwarding drain addresses.
8929#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8930enum DrainScope {
8931    /// Whatever endpoint is active for the module id: every plain stop,
8932    /// restart and reload. Also moves the module's state to `Draining`.
8933    Active,
8934    /// One specific endpoint: a swap's incumbent after cutover. Draining it by
8935    /// module id would resolve to the promoted candidate and leave neither
8936    /// process routable. The module's state is left alone, since the promoted
8937    /// candidate is what it describes and that process is running.
8938    Endpoint(crate::ModuleEndpointId),
8939}
8940
8941/// Whether a child being drained has already been asked to stop by the time
8942/// its drain wait starts.
8943///
8944/// The drain wait is the same budget whatever this says. What it decides is
8945/// whether the supervisor must ask by signal before that wait begins: a child
8946/// that nobody asked will sit out the whole budget and then be SIGKILLed,
8947/// healthy or not.
8948#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8949enum StopNotice {
8950    /// The module was sent `module.draining` and a module GOODBYE over its own
8951    /// registered connection, and stops itself.
8952    SentOverConnection,
8953    /// The forwarding drain found no registered connection for the module: a
8954    /// subc child spawned moments ago that has not sent HELLO yet, or a
8955    /// `protocol: "none"` child, which never registers.
8956    NoConnection,
8957    /// This path sends nothing over the module's connection: the supervisor has
8958    /// no forwarding table, or the caller stops the child without a forwarding
8959    /// drain.
8960    NotSent,
8961}
8962
8963async fn begin_forwarding_drain(
8964    spec: &ModuleSpec,
8965    runtime: &SupervisorRuntimeConfig,
8966    registry: &Registry,
8967    snapshot: &SharedSnapshot,
8968    enabled: Option<bool>,
8969    reason: RouteCloseReason,
8970) -> Result<StopNotice, SuperviseError> {
8971    let Some(forwarding) = runtime.forwarding.as_ref() else {
8972        return Err(SuperviseError::ReloadUnavailable {
8973            module_id: spec.module_id.clone(),
8974            reason: "supervisor was not configured with a forwarding table".to_string(),
8975        });
8976    };
8977
8978    begin_forwarding_drain_with(
8979        forwarding,
8980        ForwardingDrainContext {
8981            spec,
8982            runtime,
8983            registry,
8984            scope: DrainScope::Active,
8985        },
8986        snapshot,
8987        enabled,
8988        reason,
8989        runtime.drain_timeout,
8990    )
8991    .await
8992}
8993
8994async fn begin_forwarding_drain_if_configured(
8995    spec: &ModuleSpec,
8996    runtime: &SupervisorRuntimeConfig,
8997    registry: &Registry,
8998    snapshot: &SharedSnapshot,
8999    enabled: Option<bool>,
9000    reason: RouteCloseReason,
9001) -> Result<StopNotice, SuperviseError> {
9002    begin_forwarding_drain_with_timeout(
9003        spec,
9004        runtime,
9005        registry,
9006        snapshot,
9007        enabled,
9008        reason,
9009        runtime.drain_timeout,
9010    )
9011    .await
9012}
9013
9014/// Like [`begin_forwarding_drain_if_configured`] but with an explicit drain
9015/// budget, for paths where the operator overrides the module's configured one
9016/// (`supervisor.restart{drain_timeout_ms}`).
9017async fn begin_forwarding_drain_with_timeout(
9018    spec: &ModuleSpec,
9019    runtime: &SupervisorRuntimeConfig,
9020    registry: &Registry,
9021    snapshot: &SharedSnapshot,
9022    enabled: Option<bool>,
9023    reason: RouteCloseReason,
9024    drain_timeout: Duration,
9025) -> Result<StopNotice, SuperviseError> {
9026    let Some(forwarding) = runtime.forwarding.as_ref() else {
9027        return Ok(StopNotice::NotSent);
9028    };
9029
9030    begin_forwarding_drain_with(
9031        forwarding,
9032        ForwardingDrainContext {
9033            spec,
9034            runtime,
9035            registry,
9036            scope: DrainScope::Active,
9037        },
9038        snapshot,
9039        enabled,
9040        reason,
9041        drain_timeout,
9042    )
9043    .await
9044}
9045
9046async fn begin_forwarding_drain_with(
9047    forwarding: &ForwardingTable,
9048    context: ForwardingDrainContext<'_>,
9049    snapshot: &SharedSnapshot,
9050    enabled: Option<bool>,
9051    reason: RouteCloseReason,
9052    drain_timeout: Duration,
9053) -> Result<StopNotice, SuperviseError> {
9054    let ForwardingDrainContext {
9055        spec,
9056        runtime,
9057        registry,
9058        scope,
9059    } = context;
9060    debug_assert_ne!(reason, RouteCloseReason::Crash);
9061    let terminal = matches!(reason, RouteCloseReason::Disable);
9062    let drain_started_at = Instant::now();
9063    let drain_deadline = drain_started_at + drain_timeout;
9064    let deadline_ms =
9065        unix_ms_now().saturating_add(u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX));
9066    let busy_gauges = match scope {
9067        DrainScope::Active => declared_busy_gauges(registry, &spec.module_id)?,
9068        DrainScope::Endpoint(endpoint) => {
9069            declared_busy_gauges_for_connection(registry, endpoint.connection_id)?
9070        }
9071    };
9072
9073    // Admission gate first: route.open/commit and route REQUEST admission are closed
9074    // before the first quiescence check, so the outstanding count can only fall.
9075    let gate_started = Instant::now();
9076    let drain_target = match scope {
9077        DrainScope::Active => forwarding.begin_module_drain(&spec.module_id, reason),
9078        DrainScope::Endpoint(endpoint) => forwarding.begin_endpoint_drain(endpoint, reason),
9079    }
9080    .map_err(SuperviseError::Forwarding)?;
9081    // The instant admission closed, and how long taking the forwarding write
9082    // lock to close it took. The timeout line reports only the quiescence
9083    // wait, so without this a drain that started late looked like one that
9084    // started on time.
9085    info!(
9086        module_id = %spec.module_id,
9087        ?reason,
9088        gate_ms = u64::try_from(gate_started.elapsed().as_millis()).unwrap_or(u64::MAX),
9089        connected = drain_target.is_some(),
9090        "module drain began; route admission closed"
9091    );
9092    if scope == DrainScope::Active {
9093        update_snapshot(snapshot, Some(&spec.module_id), |state| {
9094            state.state = ModuleState::Draining;
9095            state.draining_to_replace =
9096                matches!(reason, RouteCloseReason::Restart | RouteCloseReason::Reload);
9097            if let Some(enabled) = enabled {
9098                state.enabled = enabled;
9099            }
9100        })?;
9101    }
9102
9103    let Some(target) = drain_target.as_ref() else {
9104        // Nothing was sent: the module has no registered connection to carry
9105        // `module.draining` or a GOODBYE. The caller must not assume the child
9106        // was asked to stop.
9107        return Ok(StopNotice::NoConnection);
9108    };
9109    {
9110        send_module_draining(&spec.module_id, reason, deadline_ms, target);
9111        let routes = forwarding
9112            .endpoint_routes(target.endpoint)
9113            .map_err(SuperviseError::Forwarding)?;
9114        let routes_notified = routes.len();
9115        crate::control::send_route_control_pushes(
9116            forwarding,
9117            routes.clone(),
9118            ClientControlPush::RouteClosing {
9119                module_id: spec.module_id.clone(),
9120                channels: Vec::new(),
9121                reason,
9122            },
9123        );
9124        send_route_goodbyes(forwarding, target.abandoned_bindings.clone());
9125
9126        // `route.closing` was just sent above: from here on every return path,
9127        // including an early one, MUST send `route.closed` before propagating
9128        // anything else. A client holds `closing` as a promise that a verdict is
9129        // coming; leaving early without `closed` strands it waiting forever, since
9130        // `closing` carries no timeout of its own.
9131        let wait_result = wait_for_forwarding_quiescence(
9132            forwarding,
9133            &spec.module_id,
9134            runtime,
9135            target.endpoint,
9136            drain_deadline,
9137            &busy_gauges,
9138            scope,
9139        )
9140        .await;
9141        let drained = drained_after_quiescence_wait(&wait_result);
9142        if let Err(err) = &wait_result {
9143            error!(
9144                module_id = %spec.module_id,
9145                ?reason,
9146                error = %err,
9147                "forwarding quiescence wait failed after route.closing; forcing route.closed(drained: false) so the client is not left waiting on an unfulfilled promise"
9148            );
9149        } else if !drained {
9150            // Name what the drain waited on. Without it the line says only that
9151            // something did not settle, and "one wedged call" and "every
9152            // session's held stream" read the same; the first is a module bug,
9153            // the second is a module that should end its streams on
9154            // module.draining. Read before teardown releases the routes.
9155            let holdouts = forwarding
9156                .endpoint_drain_holdouts(target.endpoint)
9157                .unwrap_or_default();
9158            warn!(
9159                module_id = %spec.module_id,
9160                waited = ?drain_timeout,
9161                ?reason,
9162                held_requests = holdouts.requests,
9163                held_routes = holdouts.routes,
9164                total_routes = holdouts.total_routes,
9165                top_connections = ?holdouts.top_connections,
9166                // `module_channel:corr`, so the module can find each held request
9167                // in its own log; capped, so `held_requests` is the full count.
9168                held = %holdouts
9169                    .held
9170                    .iter()
9171                    .map(|(channel, corr)| format!("{channel}:{corr}"))
9172                    .collect::<Vec<_>>()
9173                    .join(","),
9174                "route drain timed out before request quiescence; forcing teardown"
9175            );
9176        }
9177        crate::control::send_route_control_pushes(
9178            forwarding,
9179            routes,
9180            ClientControlPush::RouteClosed {
9181                module_id: spec.module_id.clone(),
9182                channels: Vec::new(),
9183                reason,
9184                drained,
9185                abandoned: target.abandoned_bindings.len() as u32,
9186                excluded_subscriptions: target.excluded_subscriptions,
9187                terminal: Some(terminal),
9188            },
9189        );
9190        wait_result?;
9191
9192        // `route.closed` has now been sent unconditionally above. From here the
9193        // remaining steps are cleanup (route + module GOODBYE) rather than a
9194        // promise the client is waiting on, but a lock-poisoned
9195        // `release_module_endpoint_routes` would otherwise skip the module
9196        // GOODBYE silently too -- send it before propagating the error.
9197        let released_routes = match forwarding.release_module_endpoint_routes(target.endpoint) {
9198            Ok(routes) => routes,
9199            Err(err) => {
9200                warn!(
9201                    module_id = %spec.module_id,
9202                    ?reason,
9203                    error = %err,
9204                    "failed to release module endpoint routes after route.closed; module GOODBYE will still be sent"
9205                );
9206                send_module_goodbye(&spec.module_id, forwarding, target);
9207                return Err(SuperviseError::Forwarding(err));
9208            }
9209        };
9210        let route_goodbye_count = released_routes.len();
9211        send_route_goodbyes(forwarding, released_routes);
9212        send_module_goodbye(&spec.module_id, forwarding, target);
9213
9214        // The drain's happy path was previously silent: every emission above is
9215        // best-effort with only its failure arm logged, so "were consumers told"
9216        // was unprovable from the daemon log (surfaced by a 30-minute consumer
9217        // hang where the open question was exactly whether teardown notice went
9218        // out). One summary line makes that class decidable in one grep.
9219        info!(
9220            module_id = %spec.module_id,
9221            ?reason,
9222            routes_notified,
9223            route_goodbyes = route_goodbye_count,
9224            abandoned_reservations = target.abandoned_bindings.len(),
9225            excluded_subscriptions = target.excluded_subscriptions,
9226            drained,
9227            "module drain complete; consumers notified via route.closing/route.closed pushes and per-route GOODBYE frames"
9228        );
9229    }
9230
9231    Ok(StopNotice::SentOverConnection)
9232}
9233
9234/// Wait for the freshly spawned child to take the ACTIVE slot for `module_id`,
9235/// the only slot a plain (non-swap) spawn can register into.
9236async fn wait_for_registration_after_reload(
9237    registry: &Registry,
9238    module_id: &str,
9239    snapshot: &SharedSnapshot,
9240    child: &mut SupervisedChild,
9241    wait: Duration,
9242) -> Result<RegistrationWaitOutcome, SuperviseError> {
9243    wait_for_slot_registration(
9244        registry,
9245        crate::registry::RegistrationSlot::Active(module_id),
9246        module_id,
9247        snapshot,
9248        child,
9249        wait,
9250    )
9251    .await
9252}
9253
9254/// Wait for `child` to register into `slot`, or to exit, or for `wait` to pass.
9255///
9256/// Keyed on the slot rather than the bare module id because during a swap the
9257/// id's active slot is already held by the incumbent: an id-keyed wait would
9258/// report the incumbent's registration as the candidate's and a candidate that
9259/// never registers would look registered. A swap candidate waits on
9260/// `crate::registry::RegistrationSlot::Candidate`.
9261async fn wait_for_slot_registration(
9262    registry: &Registry,
9263    slot: crate::registry::RegistrationSlot<'_>,
9264    module_id: &str,
9265    snapshot: &SharedSnapshot,
9266    child: &mut SupervisedChild,
9267    wait: Duration,
9268) -> Result<RegistrationWaitOutcome, SuperviseError> {
9269    let deadline = Instant::now() + wait;
9270    loop {
9271        if registry
9272            .registration(slot)
9273            .map_err(SuperviseError::Registry)?
9274            .is_some()
9275        {
9276            return Ok(RegistrationWaitOutcome::Registered);
9277        }
9278
9279        let now = Instant::now();
9280        if now >= deadline {
9281            return Ok(RegistrationWaitOutcome::TimedOut);
9282        }
9283        let remaining = deadline.saturating_duration_since(now);
9284        let poll = remaining.min(REGISTRY_RELEASE_POLL);
9285
9286        tokio::select! {
9287            wait_result = child.wait() => {
9288                let status = wait_result.map_err(|source| SuperviseError::Wait {
9289                    module_id: module_id.to_string(),
9290                    source,
9291                })?;
9292                return Ok(RegistrationWaitOutcome::Exited(classify_reaped_child_exit(
9293                    snapshot,
9294                    child,
9295                    &status,
9296                )));
9297            }
9298            _ = sleep(poll) => {}
9299        }
9300    }
9301}
9302
9303fn registration_failure_exit_report(mut exit_report: ExitReport) -> ExitReport {
9304    // A replacement process that exits before HELLO did not provide service, even
9305    // if it used status 0. Count it against the restart cap as a new-binary failure.
9306    if exit_report.kind != ExitKind::DeliberateSeverance {
9307        exit_report.kind = ExitKind::Crash;
9308    }
9309    exit_report
9310}
9311
9312async fn handle_reload_child_registration_failure(
9313    spec: &ModuleSpec,
9314    runtime: &SupervisorRuntimeConfig,
9315    registry: &Registry,
9316    process_liveness: &SupervisorProcessLiveness,
9317    snapshot: &SharedSnapshot,
9318    _child: &mut Option<SupervisedChild>,
9319    failure: ReloadRegistrationFailure,
9320) -> Result<(), SuperviseError> {
9321    let ReloadRegistrationFailure {
9322        exit_report,
9323        reason,
9324    } = failure;
9325    match on_child_exit(
9326        spec,
9327        runtime.restart_policy,
9328        registry,
9329        snapshot,
9330        &runtime.terminal_ring,
9331        &runtime.spawn_events,
9332        &runtime.child_roster,
9333        exit_report,
9334    )
9335    .await
9336    {
9337        NextAction::Stop {
9338            registration_released,
9339        } => {
9340            if registration_released {
9341                process_liveness.untrack_if_current(&spec.module_id, snapshot);
9342            }
9343        }
9344        NextAction::Restart { schedule } => {
9345            let delay = schedule.map_or(runtime.restart_policy.delay_for_restart(0), |schedule| {
9346                schedule.delay
9347            });
9348            if let Some(schedule) = schedule {
9349                log_crash_respawn(&spec.module_id, schedule);
9350            }
9351            schedule_respawn(
9352                runtime,
9353                snapshot,
9354                &spec.module_id,
9355                delay,
9356                RespawnKind::Spawn,
9357            )?;
9358        }
9359    }
9360    Err(SuperviseError::ReloadFailed {
9361        module_id: spec.module_id.clone(),
9362        reason,
9363    })
9364}
9365
9366async fn handle_reload_spawn_failure(
9367    spec: &ModuleSpec,
9368    runtime: &SupervisorRuntimeConfig,
9369    process_liveness: &SupervisorProcessLiveness,
9370    snapshot: &SharedSnapshot,
9371    _child: &mut Option<SupervisedChild>,
9372    reason: String,
9373) -> Result<(), SuperviseError> {
9374    let now = Instant::now();
9375    let mut schedule = None;
9376    update_snapshot(snapshot, Some(&spec.module_id), |state| {
9377        clear_current_process_facts(state);
9378        if state.enabled {
9379            schedule = state.next_crash_restart(&runtime.restart_policy, now);
9380            state.state = if schedule.is_some() {
9381                ModuleState::Restarting
9382            } else {
9383                ModuleState::Failed
9384            };
9385        } else {
9386            state.state = ModuleState::Disabled;
9387        }
9388    })?;
9389    if let Some(schedule) = schedule {
9390        schedule_respawn(
9391            runtime,
9392            snapshot,
9393            &spec.module_id,
9394            schedule.delay,
9395            RespawnKind::Spawn,
9396        )?;
9397    } else {
9398        process_liveness.untrack_if_current(&spec.module_id, snapshot);
9399    }
9400    Err(SuperviseError::ReloadFailed {
9401        module_id: spec.module_id.clone(),
9402        reason,
9403    })
9404}
9405
9406fn control_flags() -> Flags {
9407    Flags::new(false, Priority::Passive, false)
9408}
9409
9410#[allow(clippy::too_many_arguments)]
9411async fn drain_optional_child(
9412    module_id: &str,
9413    protocol: ModuleProtocol,
9414    stop_notice: StopNotice,
9415    registry: &Registry,
9416    forwarding: Option<&ForwardingTable>,
9417    snapshot: &SharedSnapshot,
9418    terminal_ring: &Arc<Mutex<TerminalRing>>,
9419    spawn_events: &SpawnEventFeed,
9420    child: &mut Option<SupervisedChild>,
9421    drain_timeout: Duration,
9422    final_state: ModuleState,
9423    enabled: Option<bool>,
9424) -> Result<(), SuperviseError> {
9425    if let Some(child) = child.take() {
9426        drain_child_to_state(
9427            module_id,
9428            protocol,
9429            stop_notice,
9430            registry,
9431            forwarding,
9432            snapshot,
9433            terminal_ring,
9434            spawn_events,
9435            child,
9436            drain_timeout,
9437            final_state,
9438            enabled,
9439        )
9440        .await
9441    } else {
9442        update_snapshot(snapshot, Some(module_id), |state| {
9443            state.state = final_state;
9444            if let Some(enabled) = enabled {
9445                state.enabled = enabled;
9446            }
9447            clear_current_process_facts(state);
9448        })?;
9449        release_dead_registration(registry, forwarding, snapshot, module_id).await
9450    }
9451}
9452
9453#[allow(clippy::too_many_arguments)]
9454async fn drain_child_to_state(
9455    module_id: &str,
9456    _protocol: ModuleProtocol,
9457    stop_notice: StopNotice,
9458    registry: &Registry,
9459    forwarding: Option<&ForwardingTable>,
9460    snapshot: &SharedSnapshot,
9461    terminal_ring: &Arc<Mutex<TerminalRing>>,
9462    spawn_events: &SpawnEventFeed,
9463    mut child: SupervisedChild,
9464    drain_timeout: Duration,
9465    final_state: ModuleState,
9466    enabled: Option<bool>,
9467) -> Result<(), SuperviseError> {
9468    let protocol = child.protocol;
9469    update_snapshot(snapshot, Some(module_id), |state| {
9470        state.state = ModuleState::Draining;
9471        state.draining_to_replace = final_state == ModuleState::Restarting;
9472        if let Some(enabled) = enabled {
9473            state.enabled = enabled;
9474        }
9475    })?;
9476
9477    // The wait below is the same budget in every case; what differs is
9478    // whether anything has ASKED the child to stop before it starts. Only a
9479    // forwarding drain that reached the module's registered connection has
9480    // (`module.draining`, then a module GOODBYE). Every other child was told
9481    // nothing: a `protocol: "none"` module, which never registers; a subc
9482    // module spawned moments ago that has not sent HELLO yet; or a stop that
9483    // runs no forwarding drain. Without a signal the budget is only a delay
9484    // in front of SIGKILL -- and the not-yet-registered child is the worst
9485    // case, because it registers into a module that is already draining,
9486    // is never told, and is killed while healthy.
9487    if stop_notice != StopNotice::SentOverConnection {
9488        if protocol == ModuleProtocol::Subc && stop_notice == StopNotice::NoConnection {
9489            info!(
9490                module_id,
9491                pid = child.pid,
9492                budget_ms = u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX),
9493                "module has no connection yet; requesting stop by signal"
9494            );
9495        }
9496        request_graceful_stop(module_id, &child);
9497    }
9498
9499    let exit_report = match timeout(drain_timeout, child.wait()).await {
9500        Ok(Ok(status)) => classify_reaped_child_exit(snapshot, &child, &status),
9501        Ok(Err(source)) => {
9502            fail_after_wait_error(
9503                module_id,
9504                snapshot,
9505                terminal_ring,
9506                spawn_events,
9507                format!("failed to wait during drain: {source}"),
9508            );
9509            return Err(SuperviseError::Wait {
9510                module_id: module_id.to_string(),
9511                source,
9512            });
9513        }
9514        Err(_) => {
9515            // Mirror the sibling arm above: state is already `Draining`, and an
9516            // error propagated from here would strand it there -- a state
9517            // `set_enabled(true)` cannot heal (`revive_terminal` matches only
9518            // `Failed | Stopped`), leaving an operator Restart as the only exit.
9519            // `Failed` before `?` keeps the module operator-visible and
9520            // revivable. Trigger is an ESRCH race (process exits between the
9521            // drain timeout firing and the kill) or a post-kill wait failure
9522            // (issue #34).
9523            //
9524            // Logged because the kill is otherwise visible only as signal 9 in
9525            // the terminal ring, and the budget it follows can be long enough
9526            // that consumers see a stretch of refusals with no stated cause.
9527            warn!(
9528                module_id,
9529                pid = child.pid,
9530                budget_ms = u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX),
9531                reason = ?final_state,
9532                ?stop_notice,
9533                "drain budget expired before the module exited; killing it"
9534            );
9535            child.start_kill().map_err(|source| {
9536                fail_after_wait_error(
9537                    module_id,
9538                    snapshot,
9539                    terminal_ring,
9540                    spawn_events,
9541                    format!("failed to kill during drain: {source}"),
9542                );
9543                SuperviseError::Kill {
9544                    module_id: module_id.to_string(),
9545                    source,
9546                }
9547            })?;
9548            let status = child.wait().await.map_err(|source| {
9549                fail_after_wait_error(
9550                    module_id,
9551                    snapshot,
9552                    terminal_ring,
9553                    spawn_events,
9554                    format!("failed to wait after drain kill: {source}"),
9555                );
9556                SuperviseError::Wait {
9557                    module_id: module_id.to_string(),
9558                    source,
9559                }
9560            })?;
9561            classify_reaped_child_exit(snapshot, &child, &status)
9562        }
9563    };
9564
9565    let detail = lock_snapshot(snapshot)?.drain_disposition_detail.take();
9566    publish_after_terminal_record(
9567        module_id,
9568        || {
9569            record_terminal_with_detail(
9570                module_id,
9571                terminal_ring,
9572                spawn_events,
9573                &exit_report,
9574                terminal_disposition(final_state),
9575                detail,
9576            )
9577        },
9578        || {
9579            update_snapshot(snapshot, Some(module_id), |state| {
9580                state.state = final_state;
9581                if let Some(enabled) = enabled {
9582                    state.enabled = enabled;
9583                }
9584                clear_current_process_facts(state);
9585                state.last_exit = Some(exit_report.clone());
9586                if exit_report.kind == ExitKind::DeliberateSeverance {
9587                    state.lifetime_restarts += 1;
9588                }
9589            })
9590        },
9591    )?;
9592    child.drain_stderr(module_id).await;
9593
9594    release_dead_registration(registry, forwarding, snapshot, module_id).await
9595}
9596
9597/// Ask a child that nothing else has asked to stop, by signal.
9598///
9599/// A registered subc module is asked over its own connection: the drain sends
9600/// `route.closing`/`route.closed` to its consumers, a GOODBYE per route, then a
9601/// module GOODBYE, and the module stops itself. A module that speaks no subc
9602/// wire receives none of that, and neither does a subc module that has not
9603/// registered yet, so for them the drain budget would be pure delay in front of
9604/// a SIGKILL -- and for a process with a store to flush (JetStream is the
9605/// reason `protocol: "none"` exists) a SIGKILL turns every ordinary teardown
9606/// into a recovery on the next start.
9607///
9608/// NEVER CALLED FOR A MODULE THAT WAS TOLD OVER ITS CONNECTION, and that is a
9609/// rule rather than an optimisation: that module's graceful stop is already
9610/// running by the time its child is drained, and a signal would race it.
9611///
9612/// Best-effort by construction. A child that has already exited is the ordinary
9613/// case rather than an error (the kill lands on a reaped or exiting pid), so a
9614/// failure is logged at debug and the wait-then-kill below still decides the
9615/// outcome.
9616#[cfg(unix)]
9617fn request_graceful_stop(module_id: &str, child: &SupervisedChild) {
9618    let Some(pid) = child
9619        .id()
9620        .and_then(|pid| i32::try_from(pid).ok())
9621        .and_then(rustix::process::Pid::from_raw)
9622    else {
9623        debug!(
9624            module_id,
9625            "no pid to signal for teardown; falling through to the drain wait"
9626        );
9627        return;
9628    };
9629    match rustix::process::kill_process(pid, rustix::process::Signal::TERM) {
9630        Ok(()) => debug!(
9631            module_id,
9632            "sent SIGTERM to a module nothing else asked to stop"
9633        ),
9634        Err(err) => debug!(
9635            module_id,
9636            error = %err,
9637            "SIGTERM to module failed; the drain wait and kill still apply"
9638        ),
9639    }
9640}
9641
9642/// Windows has no SIGTERM and no portable stand-in for one. The graceful stops
9643/// Windows does offer need cooperation this supervisor cannot assume: a console
9644/// control event requires sharing a console with the child, and `WM_CLOSE`
9645/// requires the child to pump a message loop. A supervised server process does
9646/// neither, so there is nothing to send and teardown is the wait followed by the
9647/// kill. Emulating a signal here would mean inventing a stop protocol, which is
9648/// the thing `protocol: "none"` exists to avoid.
9649#[cfg(not(unix))]
9650fn request_graceful_stop(module_id: &str, _child: &SupervisedChild) {
9651    debug!(
9652        module_id,
9653        "no graceful stop signal exists on this platform; teardown of a module nothing asked to stop waits, then kills"
9654    );
9655}
9656
9657fn terminal_disposition(final_state: ModuleState) -> TerminalDisposition {
9658    match final_state {
9659        ModuleState::Stopped => TerminalDisposition::Stopped,
9660        ModuleState::Disabled => TerminalDisposition::Disabled,
9661        ModuleState::Restarting => TerminalDisposition::Restarting,
9662        ModuleState::Failed => TerminalDisposition::Failed,
9663        ModuleState::Starting
9664        | ModuleState::Running
9665        | ModuleState::Unresponsive
9666        | ModuleState::Draining => {
9667            unreachable!("terminal exits only finish in terminal or restarting states")
9668        }
9669    }
9670}
9671
9672/// Release a reaped child's registration before allowing another spawn.
9673///
9674/// EOF is not a process-lifetime signal: an inherited socket can stay open
9675/// indefinitely, and serial frame dispatch can be waiting on egress instead of
9676/// reading EOF. After the normal release grace, request connection close (which
9677/// cancels both reads and dispatch), then allow one more release grace for the
9678/// connection guard's forwarding cleanup. Never evict a different connection.
9679async fn release_dead_registration(
9680    registry: &Registry,
9681    forwarding: Option<&ForwardingTable>,
9682    snapshot: &SharedSnapshot,
9683    module_id: &str,
9684) -> Result<(), SuperviseError> {
9685    let result = async {
9686        let registration = registry
9687            .get_module(module_id)
9688            .map_err(SuperviseError::Registry)?;
9689        match wait_for_registration_release(registry, module_id, REGISTRY_RELEASE_TIMEOUT).await {
9690            Ok(()) => return Ok(()),
9691            Err(SuperviseError::RegistrationStillActive { .. }) => {}
9692            Err(err) => return Err(err),
9693        }
9694        let pid = lock_snapshot(snapshot)?.reaped_pid;
9695        if let (Some(registration), Some(forwarding), Some(pid)) = (registration, forwarding, pid) {
9696            warn!(
9697                module_id,
9698                pid,
9699                connection_id = registration.connection_id.get(),
9700                "reaped module registration outlived release grace; closing dead connection"
9701            );
9702            forwarding.request_connection_close(
9703                registration.connection_id,
9704                CloseReason::new(
9705                    "supervised_process_reaped",
9706                    format!("module '{module_id}' pid {pid} exited"),
9707                ),
9708            );
9709            wait_for_slot_registration_release(
9710                registry,
9711                crate::registry::RegistrationSlot::Connection(registration.connection_id),
9712                REGISTRY_RELEASE_TIMEOUT,
9713            )
9714            .await?;
9715        }
9716        wait_for_registration_release(registry, module_id, Duration::ZERO).await
9717    }
9718    .await;
9719    if let Err(err) = &result {
9720        fail_snapshot(snapshot, Some(module_id), None);
9721        error!(module_id, error = %err, "registration release failed after child exit; module is failed and start can retry");
9722    }
9723    result
9724}
9725
9726/// Wait for the ACTIVE registration of `module_id` to go away, which is what a
9727/// plain stop or restart waits for before it spawns a replacement.
9728async fn wait_for_registration_release(
9729    registry: &Registry,
9730    module_id: &str,
9731    wait: Duration,
9732) -> Result<(), SuperviseError> {
9733    wait_for_slot_registration_release(
9734        registry,
9735        crate::registry::RegistrationSlot::Active(module_id),
9736        wait,
9737    )
9738    .await
9739}
9740
9741/// Wait for the registration in `slot` to go away.
9742///
9743/// Keyed on the slot rather than the bare module id because a successful swap
9744/// never empties the id's active slot (the promoted candidate is in it), so an
9745/// id-keyed wait for the incumbent's release would always time out. Draining a
9746/// swap's incumbent waits on `crate::registry::RegistrationSlot::Connection` with the
9747/// incumbent's connection instead.
9748async fn wait_for_slot_registration_release(
9749    registry: &Registry,
9750    slot: crate::registry::RegistrationSlot<'_>,
9751    wait: Duration,
9752) -> Result<(), SuperviseError> {
9753    let deadline = Instant::now() + wait;
9754    let mut release_events = registration_release_events().subscribe();
9755    let still_active = |registration: &crate::registry::ModuleRegistration| {
9756        SuperviseError::RegistrationStillActive {
9757            module_id: registration.manifest.module_id.clone(),
9758            waited: wait,
9759        }
9760    };
9761    loop {
9762        let _observed_generation = *release_events.borrow_and_update();
9763        let Some(registration) = registry
9764            .registration(slot)
9765            .map_err(SuperviseError::Registry)?
9766        else {
9767            return Ok(());
9768        };
9769
9770        let now = Instant::now();
9771        if now >= deadline {
9772            return Err(still_active(&registration));
9773        }
9774
9775        let remaining = deadline.saturating_duration_since(now);
9776        match timeout(remaining, release_events.changed()).await {
9777            Ok(Ok(())) | Ok(Err(_)) => {}
9778            Err(_) => return Err(still_active(&registration)),
9779        }
9780    }
9781}
9782
9783#[cfg(test)]
9784mod slot_registration_wait_tests {
9785    use super::*;
9786    use crate::registry::{ConnectionId, RegistrationSlot};
9787    use subc_protocol::manifest::ModuleManifest;
9788
9789    #[tokio::test]
9790    async fn enable_release_failure_is_failed_and_a_second_enable_retries() {
9791        let registry = Arc::new(Registry::default());
9792        let supervisor = Supervisor::new_for_test(Arc::clone(&registry), RestartPolicy::default());
9793        let runtime = supervisor.runtime_config();
9794        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::disabled()));
9795        let spec = ModuleSpec {
9796            module_id: "enable-stale-registration".to_string(),
9797            program: PathBuf::from("/missing/enable-retry-test"),
9798            args: Vec::new(),
9799            env: Vec::new(),
9800            reserved: false,
9801            reserved_prefixes: Vec::new(),
9802            protocol: ModuleProtocol::Subc,
9803            overlap: Default::default(),
9804        };
9805        let connection = ConnectionId::new(90);
9806        registry
9807            .register_with_control_ops(
9808                ModuleManifest::builder(&spec.module_id, "0.1.0").build(),
9809                1,
9810                connection,
9811                Vec::new(),
9812            )
9813            .unwrap();
9814        let mut child = None;
9815        let err = set_child_enabled(
9816            &spec,
9817            &runtime,
9818            &registry,
9819            &supervisor.process_liveness,
9820            &snapshot,
9821            &mut child,
9822            true,
9823        )
9824        .await
9825        .unwrap_err();
9826        assert!(matches!(
9827            err,
9828            SuperviseError::RegistrationStillActive { .. }
9829        ));
9830        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
9831        assert!(child.is_none());
9832        registry.deregister_connection(connection).unwrap();
9833        let err = set_child_enabled(
9834            &spec,
9835            &runtime,
9836            &registry,
9837            &supervisor.process_liveness,
9838            &snapshot,
9839            &mut child,
9840            true,
9841        )
9842        .await
9843        .unwrap_err();
9844        assert!(
9845            matches!(err, SuperviseError::Spawn { .. }),
9846            "second enable must attempt a spawn: {err}"
9847        );
9848        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
9849    }
9850
9851    const INCUMBENT: u64 = 1;
9852    const CANDIDATE: u64 = 2;
9853
9854    fn swapped_registry() -> Arc<Registry> {
9855        let registry = Arc::new(Registry::default());
9856        let manifest = ModuleManifest::builder("m", "0.1.0").build();
9857        registry
9858            .register_with_control_ops(
9859                manifest.clone(),
9860                1,
9861                ConnectionId::new(INCUMBENT),
9862                Vec::new(),
9863            )
9864            .unwrap();
9865        registry
9866            .register_candidate_with_control_ops(
9867                manifest,
9868                1,
9869                ConnectionId::new(CANDIDATE),
9870                Vec::new(),
9871            )
9872            .unwrap();
9873        registry
9874    }
9875
9876    /// After a promotion the id's active slot is held by the new process, so an
9877    /// id-keyed wait for the incumbent's release can never succeed; the
9878    /// connection-keyed wait completes as soon as the incumbent deregisters.
9879    #[tokio::test]
9880    async fn incumbent_release_is_awaited_by_connection_not_by_module_id() {
9881        let registry = swapped_registry();
9882        registry.promote_candidate("m").unwrap().unwrap();
9883
9884        assert!(matches!(
9885            wait_for_registration_release(&registry, "m", Duration::from_millis(50)).await,
9886            Err(SuperviseError::RegistrationStillActive { .. })
9887        ));
9888
9889        // Still held while the incumbent's connection has not deregistered.
9890        assert!(matches!(
9891            wait_for_slot_registration_release(
9892                &registry,
9893                RegistrationSlot::Connection(ConnectionId::new(INCUMBENT)),
9894                Duration::from_millis(50),
9895            )
9896            .await,
9897            Err(SuperviseError::RegistrationStillActive { .. })
9898        ));
9899
9900        let releaser = Arc::clone(&registry);
9901        let release = tokio::spawn(async move {
9902            sleep(Duration::from_millis(20)).await;
9903            releaser
9904                .deregister_connection(ConnectionId::new(INCUMBENT))
9905                .unwrap();
9906            notify_registration_release();
9907        });
9908        wait_for_slot_registration_release(
9909            &registry,
9910            RegistrationSlot::Connection(ConnectionId::new(INCUMBENT)),
9911            Duration::from_secs(5),
9912        )
9913        .await
9914        .expect("the incumbent's own registration is released");
9915        release.await.unwrap();
9916        assert!(registry.get_module("m").unwrap().is_some());
9917    }
9918
9919    /// The candidate slot is waited on separately from the active slot: the
9920    /// incumbent's registration neither holds up nor stands in for it.
9921    #[tokio::test]
9922    async fn candidate_slot_wait_ignores_the_incumbents_registration() {
9923        let registry = swapped_registry();
9924        assert!(matches!(
9925            wait_for_slot_registration_release(
9926                &registry,
9927                RegistrationSlot::Candidate("m"),
9928                Duration::from_millis(50),
9929            )
9930            .await,
9931            Err(SuperviseError::RegistrationStillActive { .. })
9932        ));
9933        registry
9934            .deregister_connection(ConnectionId::new(CANDIDATE))
9935            .unwrap();
9936        wait_for_slot_registration_release(
9937            &registry,
9938            RegistrationSlot::Candidate("m"),
9939            Duration::from_millis(50),
9940        )
9941        .await
9942        .expect("a candidate slot with no candidate is released");
9943        assert!(registry
9944            .registration(RegistrationSlot::Active("m"))
9945            .unwrap()
9946            .is_some());
9947    }
9948}
9949
9950fn classify_exit(status: &ExitStatus) -> ExitReport {
9951    ExitReport {
9952        kind: if status.success() {
9953            ExitKind::Clean
9954        } else {
9955            ExitKind::Crash
9956        },
9957        code: status.code(),
9958        signal: exit_signal(status),
9959        at_ms: unix_ms_now(),
9960    }
9961}
9962
9963/// The terminal record for a module whose `wait()` call itself errored (e.g. the
9964/// child was already reaped out-of-band). There is no `ExitStatus` to read a code
9965/// or signal from -- `None`/`None` is the honest shape, not a guess -- but the
9966/// disposition still must be `Failed` so the terminal ring is not silently missing
9967/// an entry, matching what `fail_snapshot` records for this same arm.
9968fn wait_error_exit_report() -> ExitReport {
9969    ExitReport {
9970        kind: ExitKind::Crash,
9971        code: None,
9972        signal: None,
9973        at_ms: unix_ms_now(),
9974    }
9975}
9976
9977#[cfg(unix)]
9978fn exit_signal(status: &ExitStatus) -> Option<i32> {
9979    use std::os::unix::process::ExitStatusExt;
9980
9981    status.signal()
9982}
9983
9984#[cfg(not(unix))]
9985fn exit_signal(_status: &ExitStatus) -> Option<i32> {
9986    None
9987}
9988
9989/// Give an operator-touched module its full crash budget back.
9990///
9991/// Named for the counter it used to zero; it now empties the in-window ring,
9992/// which is the same act. `lifetime_restarts` is untouched on purpose -- the
9993/// ledger of what happened survives every operator action.
9994fn reset_restart_count(snapshot: &SharedSnapshot, module_id: &str) -> Result<(), SuperviseError> {
9995    update_snapshot(snapshot, Some(module_id), |state| {
9996        state.clear_crash_restarts();
9997    })
9998}
9999
10000fn set_running(
10001    snapshot: &SharedSnapshot,
10002    child: &SupervisedChild,
10003    module_id: &str,
10004    spawn_events: &SpawnEventFeed,
10005) -> Result<(), SuperviseError> {
10006    let mut state = snapshot.lock().map_err(|_| SuperviseError::StatePoisoned {
10007        module_id: Some(module_id.to_string()),
10008    })?;
10009    state.spawn_generation = spawn_events.emit_spawned(module_id, child.pid, child.spawned_at_ms);
10010    if std::mem::take(&mut state.coalesced_restart_pending) {
10011        let generation = state.spawn_generation;
10012        info!(module_id, "restart already satisfied by generation {generation}; coalesced pending request completed");
10013    }
10014    state.drain_disposition_detail = None;
10015    state.spawn_failure = None;
10016    state.waiting_for_previous_pid = None;
10017    // Every caller of this is a plain spawn, which always uses the primary key;
10018    // a promoted swap candidate sets the flag itself after this returns.
10019    state.in_alternate_slot = false;
10020    state.configuration_updated_since_spawn = false;
10021    state.spawned_protocol = Some(child.protocol);
10022    state.state = ModuleState::Running;
10023    state.enabled = true;
10024    state.process_alive = true;
10025    state.pid = child.id();
10026    #[cfg(windows)]
10027    {
10028        state.pipe_consumed = child
10029            .nonce_delivery
10030            .as_ref()
10031            .map(|delivery| delivery.consumed());
10032    }
10033    #[cfg(target_os = "macos")]
10034    {
10035        state.report_ready = Some(Arc::clone(&child.report_ready));
10036    }
10037    state.spawned_at_ms = Some(child.spawned_at_ms);
10038    state.spawned_from = Some(child.spawned_from.clone());
10039    state.spawned_file_identity = child.spawned_file_identity.clone();
10040    state.process_start_time = child.process_start_time;
10041    Ok(())
10042}
10043
10044fn clear_current_process_facts(state: &mut SupervisorSnapshot) {
10045    state.waiting_for_previous_pid = None;
10046    state.process_alive = false;
10047    state.spawned_protocol = None;
10048    state.pid = None;
10049    #[cfg(windows)]
10050    {
10051        state.pipe_consumed = None;
10052    }
10053    #[cfg(target_os = "macos")]
10054    {
10055        state.report_ready = None;
10056    }
10057    state.spawned_at_ms = None;
10058    state.spawned_from = None;
10059    state.spawned_file_identity = None;
10060    state.process_start_time = None;
10061    state.deliberate_severance = None;
10062}
10063
10064#[cfg(test)]
10065fn record_deliberate_severance(
10066    snapshot: &SharedSnapshot,
10067    identity: ProcessIdentity,
10068) -> Result<(), SuperviseError> {
10069    update_snapshot(snapshot, None, |state| {
10070        state.deliberate_severance = Some(identity);
10071    })
10072}
10073
10074fn apply_deliberate_severance_marker(
10075    snapshot: &SharedSnapshot,
10076    exited_identity: Option<ProcessIdentity>,
10077    mut exit_report: ExitReport,
10078) -> ExitReport {
10079    let marker = lock_snapshot(snapshot)
10080        .ok()
10081        .and_then(|mut state| state.deliberate_severance.take());
10082    if marker.is_some() && marker == exited_identity {
10083        exit_report.kind = ExitKind::DeliberateSeverance;
10084    }
10085    exit_report
10086}
10087
10088fn classify_reaped_child_exit(
10089    snapshot: &SharedSnapshot,
10090    child: &SupervisedChild,
10091    status: &ExitStatus,
10092) -> ExitReport {
10093    let _ = update_snapshot(snapshot, None, |state| {
10094        state.reaped_pid = Some(child.pid);
10095        state.spawn_failure = child.spawn_failure.clone();
10096    });
10097    apply_deliberate_severance_marker(snapshot, child.process_identity(), classify_exit(status))
10098}
10099
10100fn fail_snapshot(
10101    snapshot: &SharedSnapshot,
10102    module_id: Option<&str>,
10103    last_exit: Option<ExitReport>,
10104) {
10105    if let Err(err) = update_snapshot(snapshot, module_id, |state| {
10106        state.state = ModuleState::Failed;
10107        clear_current_process_facts(state);
10108        if let Some(last_exit) = last_exit {
10109            state.last_exit = Some(last_exit);
10110        }
10111    }) {
10112        error!(error = %err, "failed to mark supervisor state failed");
10113    }
10114}
10115
10116fn update_snapshot(
10117    snapshot: &SharedSnapshot,
10118    module_id: Option<&str>,
10119    update: impl FnOnce(&mut SupervisorSnapshot),
10120) -> Result<(), SuperviseError> {
10121    let mut state = snapshot.lock().map_err(|_| SuperviseError::StatePoisoned {
10122        module_id: module_id.map(ToOwned::to_owned),
10123    })?;
10124    update(&mut state);
10125    Ok(())
10126}
10127
10128const SLOW_SNAPSHOT_LOCK_THRESHOLD: Duration = Duration::from_millis(250);
10129
10130fn lock_snapshot_for_control<'a>(
10131    snapshot: &'a SharedSnapshot,
10132    module_id: &str,
10133    caller: &'static str,
10134) -> Result<std::sync::MutexGuard<'a, SupervisorSnapshot>, SuperviseError> {
10135    let started_at = Instant::now();
10136    let guard = lock_snapshot(snapshot)?;
10137    let waited = started_at.elapsed();
10138    if waited >= SLOW_SNAPSHOT_LOCK_THRESHOLD {
10139        warn!(
10140            module_id = %module_id,
10141            waited_ms = waited.as_millis() as u64,
10142            caller = %caller,
10143            "slow snapshot lock"
10144        );
10145    }
10146    Ok(guard)
10147}
10148
10149fn lock_snapshot(
10150    snapshot: &SharedSnapshot,
10151) -> Result<std::sync::MutexGuard<'_, SupervisorSnapshot>, SuperviseError> {
10152    snapshot
10153        .lock()
10154        .map_err(|_| SuperviseError::StatePoisoned { module_id: None })
10155}
10156
10157#[cfg(test)]
10158mod terminal_history_tests {
10159    use std::{
10160        path::PathBuf,
10161        sync::Arc,
10162        time::{Duration, Instant},
10163    };
10164
10165    use tokio::{sync::mpsc, time::sleep};
10166
10167    use super::{
10168        apply_deliberate_severance_marker, daemon_will_restart, drain_child_to_state,
10169        drained_after_quiescence_wait, handle_reload_spawn_failure, health_restart_child,
10170        lock_snapshot, on_child_exit, record_deliberate_severance, record_wait_error_terminal,
10171        reset_restart_count, spawn_and_mark_running, update_snapshot, wait_error_exit_report,
10172        ExitKind, ExitReport, ModuleProtocol, ModuleSpec, ModuleState, NextAction, ProcessIdentity,
10173        RestartPolicy, SpawnEventKind, StopNotice, SuperviseError, SupervisedConfiguration,
10174        SupervisedModule, SupervisedModuleInner, Supervisor, SupervisorHandle,
10175        SupervisorHealthStatus, SupervisorSnapshot,
10176    };
10177    // The supervisor's clock, distinct from the `std::time::Instant` these tests
10178    // use for their own wall-clock deadlines: crash-restart instants must be on
10179    // the same clock the production code stamps them with, which is tokio's (and
10180    // is what `start_paused` tests can move).
10181    use super::Instant as ClockInstant;
10182    use crate::{
10183        registry::Registry,
10184        terminal_ring::{TerminalRing, TerminalRingConfig},
10185    };
10186    use std::sync::Mutex;
10187    use subc_control::TerminalDisposition;
10188
10189    /// See the twin in `control.rs` for why this derives the path from
10190    /// `current_exe()` and why the existence check is here: `--lib` alone does
10191    /// not build `[[bin]]` targets, and a bare spawn then fails with a raw
10192    /// `NotFound` that reads as a broken test rather than an unbuilt dependency.
10193    pub(super) fn fake_aft_stub_path() -> PathBuf {
10194        let mut path = std::env::current_exe().expect("current_exe available in tests");
10195        path.pop();
10196        path.pop();
10197        path.push(if cfg!(windows) {
10198            "fake-aft-stub.exe"
10199        } else {
10200            "fake-aft-stub"
10201        });
10202        assert!(
10203            path.exists(),
10204            "fake-aft-stub not built at {}: run `cargo test -p subc-core` (which builds \
10205             [[bin]] targets) rather than `cargo test -p subc-core --lib` (which does not)",
10206            path.display()
10207        );
10208        path
10209    }
10210
10211    #[test]
10212    fn reserved_never_spawned_refuses_every_hello() {
10213        // The canary hole: a reserved id whose module has never spawned had NO
10214        // gate entry and admitted anyone -- the reservation protected the nonce
10215        // holder, not the NAME. Now the entry is present with no legitimate
10216        // holder and refuses all comers.
10217        let supervisor = SupervisorHandle::default();
10218        supervisor.apply_identity_configuration(&ModuleSpec {
10219            module_id: "never-spawned".to_string(),
10220            program: PathBuf::from("/usr/bin/false"),
10221            args: Vec::new(),
10222            env: Vec::new(),
10223            reserved: true,
10224            reserved_prefixes: Vec::new(),
10225            protocol: ModuleProtocol::Subc,
10226            overlap: Default::default(),
10227        });
10228        assert!(
10229            supervisor
10230                .reserved_hello_rejection("never-spawned", Some("any-forged-nonce"))
10231                .is_some(),
10232            "forged nonce must refuse on a reserved never-spawned id"
10233        );
10234        assert!(
10235            supervisor
10236                .reserved_hello_rejection("never-spawned", None)
10237                .is_some(),
10238            "absent nonce must refuse on a reserved never-spawned id"
10239        );
10240        // And a real spawn nonce minted later admits exactly that nonce.
10241        supervisor.set_spawn_nonce("never-spawned", "minted".to_string());
10242        supervisor.apply_identity_configuration(&ModuleSpec {
10243            module_id: "never-spawned".to_string(),
10244            program: PathBuf::from("/usr/bin/false"),
10245            args: Vec::new(),
10246            env: Vec::new(),
10247            reserved: true,
10248            reserved_prefixes: Vec::new(),
10249            protocol: ModuleProtocol::Subc,
10250            overlap: Default::default(),
10251        });
10252        assert!(supervisor
10253            .reserved_hello_rejection("never-spawned", Some("minted"))
10254            .is_none());
10255        assert!(supervisor
10256            .reserved_hello_rejection("never-spawned", Some("forged"))
10257            .is_some());
10258    }
10259
10260    /// Put `count` crash restarts on a snapshot's ring as if they had all just
10261    /// happened, which is what "spent budget" looks like to every reader.
10262    fn seed_crash_restarts(state: &mut SupervisorSnapshot, count: u32) {
10263        let now = ClockInstant::now();
10264        for _ in 0..count {
10265            state.crash_restarts.push_back(now);
10266        }
10267    }
10268
10269    /// Age the oldest recorded restart out of `window`, standing in for the hours
10270    /// that would otherwise have to pass. Injecting the instant is the point: a
10271    /// test that slept a real window would take ten minutes and still prove less.
10272    fn age_oldest_crash_restart_out_of_window(state: &mut SupervisorSnapshot, window: Duration) {
10273        let aged = state
10274            .crash_restarts
10275            .front()
10276            .expect("a crash restart must be recorded before it can be aged")
10277            .checked_sub(window + Duration::from_secs(1))
10278            .expect("the test clock is far enough from its origin to age an instant");
10279        state.crash_restarts[0] = aged;
10280    }
10281
10282    fn snapshot_with_restarts(enabled: bool, count: u32) -> SupervisorSnapshot {
10283        let mut state = SupervisorSnapshot::new(ModuleState::Running, enabled);
10284        seed_crash_restarts(&mut state, count);
10285        state
10286    }
10287
10288    #[test]
10289    fn daemon_owned_recovery_predicate_uses_the_pre_increment_budget() {
10290        let policy = RestartPolicy::new(3, Duration::ZERO);
10291        let now = ClockInstant::now();
10292        assert!(daemon_will_restart(
10293            &mut snapshot_with_restarts(true, 2),
10294            &policy,
10295            now
10296        ));
10297        assert!(!daemon_will_restart(
10298            &mut snapshot_with_restarts(true, 3),
10299            &policy,
10300            now
10301        ));
10302        assert!(!daemon_will_restart(
10303            &mut snapshot_with_restarts(false, 0),
10304            &policy,
10305            now
10306        ));
10307    }
10308
10309    #[test]
10310    fn crash_restart_backoff_escalates_with_in_window_count() {
10311        let policy = RestartPolicy::new(4, Duration::from_millis(100))
10312            .with_max_backoff(Duration::from_secs(30));
10313        let now = ClockInstant::now();
10314        let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
10315        let schedules = (0..4)
10316            .map(|_| {
10317                state
10318                    .next_crash_restart(&policy, now)
10319                    .expect("the test policy allows four crash restarts")
10320            })
10321            .collect::<Vec<_>>();
10322
10323        assert_eq!(
10324            schedules
10325                .iter()
10326                .map(|schedule| schedule.restart_in_window)
10327                .collect::<Vec<_>>(),
10328            vec![0, 1, 2, 3]
10329        );
10330        assert_eq!(
10331            schedules
10332                .iter()
10333                .map(|schedule| schedule.delay)
10334                .collect::<Vec<_>>(),
10335            vec![
10336                Duration::from_millis(100),
10337                Duration::from_secs(1),
10338                Duration::from_secs(10),
10339                Duration::from_secs(30),
10340            ]
10341        );
10342    }
10343
10344    #[test]
10345    fn crash_restart_backoff_resets_after_ring_clear() {
10346        let policy = RestartPolicy::new(3, Duration::from_millis(100));
10347        let now = ClockInstant::now();
10348        let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
10349        assert_eq!(
10350            state.next_crash_restart(&policy, now).unwrap().delay,
10351            Duration::from_millis(100)
10352        );
10353        assert_eq!(
10354            state.next_crash_restart(&policy, now).unwrap().delay,
10355            Duration::from_secs(1)
10356        );
10357
10358        state.clear_crash_restarts();
10359        let schedule = state
10360            .next_crash_restart(&policy, now)
10361            .expect("a cleared ring must allow another restart");
10362        assert_eq!(schedule.restart_in_window, 0);
10363        assert_eq!(schedule.delay, Duration::from_millis(100));
10364    }
10365
10366    #[test]
10367    fn crash_restart_backoff_ignores_aged_restarts() {
10368        let policy = RestartPolicy::new(3, Duration::from_millis(100));
10369        let now = ClockInstant::now();
10370        let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
10371        state
10372            .next_crash_restart(&policy, now)
10373            .expect("the first restart is allowed");
10374        state
10375            .next_crash_restart(&policy, now)
10376            .expect("the second restart is allowed");
10377        state.crash_restarts[0] = now
10378            .checked_sub(policy.window + Duration::from_secs(1))
10379            .expect("the fake clock can age a restart past the window");
10380
10381        let schedule = state
10382            .next_crash_restart(&policy, now)
10383            .expect("an aged restart must release its slot");
10384        assert_eq!(schedule.restart_in_window, 1);
10385        assert_eq!(schedule.delay, Duration::from_secs(1));
10386        assert_eq!(state.crash_restarts.len(), 2);
10387    }
10388
10389    /// The budget is a rate: the same three spent restarts refuse a respawn
10390    /// while they are recent and allow one once they have aged past the window.
10391    /// Nothing about the module changed in between, which is the whole point.
10392    #[test]
10393    fn a_budget_spent_before_the_window_no_longer_refuses() {
10394        let policy = RestartPolicy::new(3, Duration::ZERO);
10395        let mut state = snapshot_with_restarts(true, 3);
10396        let now = ClockInstant::now();
10397        assert!(!daemon_will_restart(&mut state, &policy, now));
10398
10399        assert!(daemon_will_restart(
10400            &mut state,
10401            &policy,
10402            now + policy.window + Duration::from_secs(1)
10403        ));
10404        assert!(
10405            state.crash_restarts.is_empty(),
10406            "reading the budget must drop the instants that left the window"
10407        );
10408    }
10409
10410    fn module_with_recovery_snapshot(
10411        state: ModuleState,
10412        enabled: bool,
10413        restart_count: u32,
10414    ) -> SupervisedModule {
10415        let registry = Arc::new(Registry::default());
10416        let supervisor =
10417            Supervisor::new_for_test(Arc::clone(&registry), RestartPolicy::new(3, Duration::ZERO));
10418        let runtime = supervisor.runtime_config();
10419        let spec = ModuleSpec {
10420            module_id: "recovery-snapshot".to_string(),
10421            program: fake_aft_stub_path(),
10422            args: Vec::new(),
10423            env: Vec::new(),
10424            reserved: false,
10425            reserved_prefixes: Vec::new(),
10426            protocol: ModuleProtocol::Subc,
10427            overlap: Default::default(),
10428        };
10429        let mut snapshot = SupervisorSnapshot::new(state, enabled);
10430        seed_crash_restarts(&mut snapshot, restart_count);
10431        // These tests read synthetic snapshots. A real child and monitor would
10432        // race those reads by replacing the requested state during startup.
10433        let (commands, _rx) = mpsc::channel(4);
10434        SupervisedModule {
10435            inner: Arc::new(SupervisedModuleInner {
10436                module_id: spec.module_id.clone(),
10437                registry,
10438                snapshot: Arc::new(Mutex::new(snapshot)),
10439                configuration: Arc::new(Mutex::new(SupervisedConfiguration {
10440                    spec,
10441                    health: runtime.health,
10442                })),
10443                stderr_ring: runtime.stderr_ring,
10444                terminal_ring: runtime.terminal_ring,
10445                commands,
10446                monitor: Mutex::new(None),
10447                restart_policy: runtime.restart_policy,
10448                effective_drain_timeout: runtime.effective_drain_timeout,
10449                provenance_probe: supervisor.provenance_probe.clone(),
10450            }),
10451        }
10452    }
10453
10454    #[cfg(target_os = "linux")]
10455    #[tokio::test]
10456    async fn no_cgroup_placement_does_not_block_fake_aft_stub_spawn() {
10457        let supervisor =
10458            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
10459                .with_cgroup_placement(None);
10460        let result = supervisor.spawn(ModuleSpec {
10461            module_id: "no-cgroup-placement".to_string(),
10462            program: fake_aft_stub_path(),
10463            args: Vec::new(),
10464            env: Vec::new(),
10465            reserved: false,
10466            reserved_prefixes: Vec::new(),
10467            protocol: ModuleProtocol::Subc,
10468            overlap: Default::default(),
10469        });
10470
10471        assert!(
10472            result.is_ok(),
10473            "no delegation must not turn an otherwise valid spawn into a failure: {result:?}"
10474        );
10475    }
10476
10477    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10478    async fn undecided_snapshot_uses_shared_restart_predicate() {
10479        assert!(module_with_recovery_snapshot(ModuleState::Running, true, 2)
10480            .will_recover_after_connection_loss()
10481            .unwrap());
10482        assert!(
10483            !module_with_recovery_snapshot(ModuleState::Running, true, 3)
10484                .will_recover_after_connection_loss()
10485                .unwrap()
10486        );
10487    }
10488
10489    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10490    async fn restarting_snapshot_at_exhausted_budget_is_non_terminal() {
10491        assert!(
10492            module_with_recovery_snapshot(ModuleState::Restarting, true, 3)
10493                .will_recover_after_connection_loss()
10494                .unwrap()
10495        );
10496    }
10497
10498    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10499    async fn terminal_phase_snapshots_are_terminal_before_budget_exhaustion() {
10500        assert!(!module_with_recovery_snapshot(ModuleState::Failed, true, 0)
10501            .will_recover_after_connection_loss()
10502            .unwrap());
10503        assert!(
10504            !module_with_recovery_snapshot(ModuleState::Disabled, true, 0)
10505                .will_recover_after_connection_loss()
10506                .unwrap()
10507        );
10508    }
10509
10510    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10511    async fn warming_snapshot_is_limited_to_startup_phases() {
10512        for state in [
10513            ModuleState::Starting,
10514            ModuleState::Running,
10515            ModuleState::Restarting,
10516        ] {
10517            assert!(
10518                module_with_recovery_snapshot(state, true, 0)
10519                    .is_warming()
10520                    .unwrap(),
10521                "{state:?} should be warming"
10522            );
10523        }
10524        for state in [
10525            ModuleState::Unresponsive,
10526            ModuleState::Draining,
10527            ModuleState::Stopped,
10528            ModuleState::Failed,
10529            ModuleState::Disabled,
10530        ] {
10531            assert!(
10532                !module_with_recovery_snapshot(state, true, 0)
10533                    .is_warming()
10534                    .unwrap(),
10535                "{state:?} should not be warming"
10536            );
10537        }
10538    }
10539
10540    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10541    async fn terminal_history_survives_respawn_and_keeps_both_crashes_in_order() {
10542        let registry = Arc::new(Registry::default());
10543        let supervisor =
10544            Supervisor::new_for_test(Arc::clone(&registry), RestartPolicy::new(1, Duration::ZERO));
10545        let module = supervisor
10546            .spawn(ModuleSpec {
10547                module_id: "terminal-history".to_string(),
10548                program: fake_aft_stub_path(),
10549                args: Vec::new(),
10550                env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
10551                reserved: false,
10552                reserved_prefixes: Vec::new(),
10553                protocol: ModuleProtocol::Subc,
10554                overlap: Default::default(),
10555            })
10556            .unwrap();
10557
10558        let deadline = Instant::now() + Duration::from_secs(5);
10559        loop {
10560            let history = module.terminal_history();
10561            if history.entries.len() == 2 {
10562                assert_eq!(module.status().unwrap().state, ModuleState::Failed);
10563                assert_eq!(history.dropped, 0);
10564                assert_eq!(
10565                    history
10566                        .entries
10567                        .iter()
10568                        .map(|entry| entry.exit_code)
10569                        .collect::<Vec<_>>(),
10570                    vec![Some(23), Some(23)]
10571                );
10572                assert!(history.entries[0].at_ms <= history.entries[1].at_ms);
10573                return;
10574            }
10575            assert!(
10576                Instant::now() < deadline,
10577                "module did not retain two terminal exits: {history:?}"
10578            );
10579            sleep(Duration::from_millis(10)).await;
10580        }
10581    }
10582
10583    /// A disable issued while a crash respawn is still backing off must preempt
10584    /// that respawn: the operator's stop wins, the disable must not queue behind
10585    /// the backoff, and the module must never come back up afterwards.
10586    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10587    async fn disable_during_crash_backoff_cancels_pending_respawn() {
10588        let backoff = Duration::from_secs(2);
10589        let supervisor = Supervisor::new_for_test(
10590            Arc::new(Registry::default()),
10591            RestartPolicy::new(10, backoff),
10592        );
10593        let module = supervisor
10594            .spawn(ModuleSpec {
10595                module_id: "disable-during-backoff".to_string(),
10596                program: fake_aft_stub_path(),
10597                args: Vec::new(),
10598                env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
10599                reserved: false,
10600                reserved_prefixes: Vec::new(),
10601                protocol: ModuleProtocol::Subc,
10602                overlap: Default::default(),
10603            })
10604            .unwrap();
10605
10606        // Wait for the first crash to put the module into its backoff window.
10607        let deadline = Instant::now() + Duration::from_secs(5);
10608        loop {
10609            if module.status().unwrap().state == ModuleState::Restarting {
10610                break;
10611            }
10612            assert!(
10613                Instant::now() < deadline,
10614                "module never entered the crash backoff"
10615            );
10616            sleep(Duration::from_millis(10)).await;
10617        }
10618
10619        let started = Instant::now();
10620        module.set_enabled(false).await.unwrap();
10621        let waited = started.elapsed();
10622
10623        assert!(
10624            waited < backoff / 2,
10625            "disable waited {waited:?} behind the {backoff:?} crash backoff; the operator command must preempt the pending respawn"
10626        );
10627        assert_eq!(module.status().unwrap().state, ModuleState::Disabled);
10628
10629        // Outlast the backoff: the respawn it was counting down to must never run.
10630        sleep(backoff + Duration::from_millis(500)).await;
10631        let status = module.status().unwrap();
10632        assert_eq!(status.state, ModuleState::Disabled);
10633        assert_eq!(
10634            status.spawn_generation, 1,
10635            "module respawned after the operator disabled it"
10636        );
10637    }
10638
10639    /// A `protocol: "none"` child that stays parked and exits 0 on SIGTERM:
10640    /// the shape of nats-server, the program this rule exists for.
10641    #[cfg(unix)]
10642    fn protocol_none_sigterm_exits_clean_spec(
10643        module_id: &str,
10644        dir: &std::path::Path,
10645    ) -> (ModuleSpec, PathBuf, PathBuf) {
10646        let ready = dir.join("ready");
10647        let marker = dir.join("sigterm");
10648        let spec = ModuleSpec {
10649            module_id: module_id.to_string(),
10650            program: fake_aft_stub_path(),
10651            args: Vec::new(),
10652            env: vec![
10653                ("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string()),
10654                (
10655                    "FAKE_AFT_SIGTERM_MARKER_PATH".to_string(),
10656                    marker.display().to_string(),
10657                ),
10658                (
10659                    "FAKE_AFT_NEVER_CONNECT_READY_PATH".to_string(),
10660                    ready.display().to_string(),
10661                ),
10662            ],
10663            reserved: false,
10664            reserved_prefixes: Vec::new(),
10665            protocol: ModuleProtocol::None,
10666            overlap: Default::default(),
10667        };
10668        (spec, ready, marker)
10669    }
10670
10671    /// Wait for a file the child writes, so a signal is never sent before the
10672    /// child's SIGTERM handler is installed (the default disposition would
10673    /// kill it by signal and the exit would not be clean).
10674    #[cfg(unix)]
10675    async fn wait_for_file(path: &std::path::Path) {
10676        let deadline = Instant::now() + Duration::from_secs(10);
10677        while !path.exists() {
10678            assert!(
10679                Instant::now() < deadline,
10680                "{} never appeared",
10681                path.display()
10682            );
10683            sleep(Duration::from_millis(10)).await;
10684        }
10685    }
10686
10687    /// A protocol-none module that exits 0 because something OUTSIDE the
10688    /// supervisor sent it SIGTERM is respawned, and the terminal record carries
10689    /// the crash-path disposition rather than `stopped`.
10690    #[cfg(unix)]
10691    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10692    async fn protocol_none_unrequested_clean_exit_restarts_as_a_crash() {
10693        let dir = cortexkit_test_support::ScratchDir::new("none-unrequested-clean-exit");
10694        let (spec, ready, marker) =
10695            protocol_none_sigterm_exits_clean_spec("none-unrequested-clean-exit", dir.path());
10696        let supervisor = Supervisor::new_for_test(
10697            Arc::new(Registry::default()),
10698            RestartPolicy::new(3, Duration::ZERO),
10699        );
10700        let module = supervisor.spawn(spec).unwrap();
10701        wait_for_file(&ready).await;
10702        // The ready file proves the child installed its SIGTERM handler, not
10703        // that the supervisor has processed the privacy trampoline's exec
10704        // acknowledgement. On macOS status withholds the pid until then.
10705        let deadline = Instant::now() + Duration::from_secs(10);
10706        let first_pid = loop {
10707            let status = module.status().unwrap();
10708            if status.state == ModuleState::Running {
10709                if let Some(pid) = status.pid {
10710                    break pid;
10711                }
10712            }
10713            assert!(
10714                Instant::now() < deadline,
10715                "a running module must report its pid after exec confirmation: {status:?}"
10716            );
10717            sleep(Duration::from_millis(10)).await;
10718        };
10719
10720        rustix::process::kill_process(
10721            rustix::process::Pid::from_raw(i32::try_from(first_pid).unwrap()).unwrap(),
10722            rustix::process::Signal::TERM,
10723        )
10724        .unwrap();
10725
10726        let deadline = Instant::now() + Duration::from_secs(10);
10727        let respawned = loop {
10728            let status = module.status().unwrap();
10729            if status.state == ModuleState::Running
10730                && status.pid.is_some_and(|pid| pid != first_pid)
10731            {
10732                break status;
10733            }
10734            assert!(
10735                Instant::now() < deadline,
10736                "protocol-none module was not respawned after an unrequested clean exit: {status:?}"
10737            );
10738            sleep(Duration::from_millis(10)).await;
10739        };
10740        assert_eq!(respawned.spawn_generation, 2);
10741        assert!(
10742            marker.exists(),
10743            "the child must have exited through its SIGTERM handler (exit 0), or this proves nothing about clean exits"
10744        );
10745
10746        let history = module.terminal_history();
10747        assert_eq!(history.entries.len(), 1, "{history:?}");
10748        let entry = &history.entries[0];
10749        assert_eq!(entry.exit_code, Some(0));
10750        assert_eq!(entry.exit_kind, subc_control::TerminalExitKind::Clean);
10751        assert_eq!(entry.disposition, TerminalDisposition::Restarting);
10752
10753        module.stop().await.unwrap();
10754    }
10755
10756    /// Repeated unrequested clean exits of a protocol-none module spend the
10757    /// restart budget exactly as crashes do, and the module ends `failed` with
10758    /// the budget named.
10759    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10760    async fn protocol_none_repeated_clean_exits_exhaust_the_restart_budget() {
10761        let supervisor = Supervisor::new_for_test(
10762            Arc::new(Registry::default()),
10763            RestartPolicy::new(1, Duration::ZERO),
10764        );
10765        let module = supervisor
10766            .spawn(ModuleSpec {
10767                module_id: "none-clean-exit-budget".to_string(),
10768                program: fake_aft_stub_path(),
10769                args: Vec::new(),
10770                env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "0".to_string())],
10771                reserved: false,
10772                reserved_prefixes: Vec::new(),
10773                protocol: ModuleProtocol::None,
10774                overlap: Default::default(),
10775            })
10776            .unwrap();
10777
10778        // Failed follows the terminal write; this deadline only bounds a hang,
10779        // not an assumed duration for the two launches or their exit recording.
10780        let deadline = Instant::now() + Duration::from_secs(10);
10781        loop {
10782            let status = module.status().unwrap();
10783            if status.state == ModuleState::Failed {
10784                break;
10785            }
10786            assert!(
10787                Instant::now() < deadline,
10788                "module never exhausted its budget: {status:?} {:?}",
10789                module.terminal_history()
10790            );
10791            sleep(Duration::from_millis(10)).await;
10792        }
10793        let history = module.terminal_history();
10794        assert_eq!(
10795            history
10796                .entries
10797                .iter()
10798                .map(|entry| (entry.exit_code, entry.disposition.clone()))
10799                .collect::<Vec<_>>(),
10800            vec![
10801                (Some(0), TerminalDisposition::Restarting),
10802                (Some(0), TerminalDisposition::Failed),
10803            ]
10804        );
10805        let detail = history.entries[1]
10806            .disposition_detail
10807            .as_deref()
10808            .expect("a budget failure names the budget");
10809        assert!(detail.contains("max_restarts=1"), "{detail}");
10810        assert_eq!(module.status().unwrap().spawn_generation, 2);
10811    }
10812
10813    #[test]
10814    fn restart_budget_failure_is_published_after_its_terminal_record() {
10815        use std::io::{Read, Write};
10816        let supervisor = Supervisor::new_for_test(
10817            Arc::new(Registry::default()),
10818            RestartPolicy::new(0, Duration::ZERO),
10819        );
10820        let runtime = supervisor.runtime_config();
10821        let spec = ModuleSpec {
10822            protocol: ModuleProtocol::None,
10823            ..windowed_crash_spec("budget-publication-order")
10824        };
10825        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(
10826            ModuleState::Running,
10827            true,
10828        )));
10829        lock_snapshot(&snapshot).unwrap().process_alive = true;
10830        let reaped_snapshot = snapshot.clone();
10831        let mut barrier = terminal_record_barrier(&runtime.spawn_events);
10832        let history = runtime.terminal_ring.clone();
10833        let reap = std::thread::spawn(move || {
10834            tokio::runtime::Builder::new_current_thread()
10835                .enable_all()
10836                .build()
10837                .unwrap()
10838                .block_on(on_child_exit(
10839                    &spec,
10840                    runtime.restart_policy,
10841                    &supervisor.registry,
10842                    &reaped_snapshot,
10843                    &runtime.terminal_ring,
10844                    &runtime.spawn_events,
10845                    &runtime.child_roster,
10846                    ExitReport {
10847                        kind: ExitKind::Clean,
10848                        code: Some(0),
10849                        signal: None,
10850                        at_ms: 1,
10851                    },
10852                ))
10853        });
10854        let mut reached = [0];
10855        barrier.read_exact(&mut reached).unwrap();
10856        assert_eq!(&reached, b"R");
10857        let (before_state, before_alive) = {
10858            let state = lock_snapshot(&snapshot).unwrap();
10859            (state.state, state.process_alive)
10860        };
10861        let before_history = history.lock().unwrap().snapshot();
10862        barrier.write_all(b"X").unwrap();
10863        assert!(matches!(reap.join().unwrap(), NextAction::Stop { .. }));
10864        assert!(before_history.entries.is_empty());
10865        assert_ne!(
10866            before_state,
10867            ModuleState::Failed,
10868            "Failed was visible before its terminal record could be written"
10869        );
10870        assert!(before_alive);
10871        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
10872        let history = history.lock().unwrap().snapshot();
10873        assert_eq!(history.entries.len(), 1);
10874        assert_eq!(history.entries[0].exit_code, Some(0));
10875        assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
10876    }
10877
10878    fn terminal_record_barrier(events: &super::SpawnEventFeed) -> std::net::TcpStream {
10879        use std::net::{TcpListener, TcpStream};
10880        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
10881        let writer = TcpStream::connect(listener.local_addr().unwrap()).unwrap();
10882        let (barrier, _) = listener.accept().unwrap();
10883        for stream in [&writer, &barrier] {
10884            stream
10885                .set_read_timeout(Some(Duration::from_secs(10)))
10886                .unwrap();
10887            stream
10888                .set_write_timeout(Some(Duration::from_secs(10)))
10889                .unwrap();
10890        }
10891        *events.1.lock().unwrap() = Some(writer);
10892        barrier
10893    }
10894
10895    pub(super) fn before_terminal_record(events: &super::SpawnEventFeed) {
10896        use std::io::{Read, Write};
10897        let barrier = events.1.lock().unwrap().take();
10898        if let Some(mut barrier) = barrier {
10899            barrier.write_all(b"R").unwrap();
10900            let mut release = [0];
10901            barrier.read_exact(&mut release).unwrap();
10902            assert_eq!(&release, b"X");
10903        }
10904    }
10905
10906    #[test]
10907    fn health_restart_budget_failure_is_published_after_its_terminal_record() {
10908        use std::io::{Read, Write};
10909
10910        let supervisor = Supervisor::new_for_test(
10911            Arc::new(Registry::default()),
10912            RestartPolicy::new(0, Duration::ZERO),
10913        );
10914        let mut runtime = supervisor.runtime_config();
10915        let module = module_with_recovery_snapshot(ModuleState::Running, true, 0);
10916        let snapshot = module.inner.snapshot.clone();
10917        runtime.terminal_ring = module.inner.terminal_ring.clone();
10918        let spec = ModuleSpec {
10919            module_id: module.module_id().into(),
10920            program: fake_aft_stub_path(),
10921            protocol: ModuleProtocol::None,
10922            env: vec![("FAKE_AFT_EXIT_CODE".into(), "17".into())],
10923            ..windowed_crash_spec(module.module_id())
10924        };
10925        let mut barrier = terminal_record_barrier(&runtime.spawn_events);
10926        let reap = std::thread::spawn(move || {
10927            tokio::runtime::Builder::new_current_thread()
10928                .enable_all()
10929                .build()
10930                .unwrap()
10931                .block_on(async {
10932                    let mut child =
10933                        Some(spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap());
10934                    health_restart_child(
10935                        &spec,
10936                        &runtime,
10937                        &supervisor.registry,
10938                        &supervisor.process_liveness,
10939                        &snapshot,
10940                        &mut child,
10941                        SupervisorHealthStatus::Failing,
10942                        Some("health probe did not answer"),
10943                        1,
10944                    )
10945                    .await
10946                    .unwrap();
10947                    assert!(child.is_none());
10948                });
10949        });
10950        // The loopback barrier acknowledges entry into terminal recording by
10951        // health_restart_child's drain, then waits for the test's release byte.
10952        // Status and history are observed while that writer is held, not after
10953        // an elapsed-time guess about when the health action has completed.
10954        let mut reached = [0];
10955        barrier.read_exact(&mut reached).unwrap();
10956        assert_eq!(&reached, b"R");
10957        let before_status = module.status().unwrap();
10958        let before_history = module.terminal_history();
10959        barrier.write_all(b"X").unwrap();
10960        reap.join().unwrap();
10961        assert!(before_history.entries.is_empty());
10962        assert_ne!(
10963            before_status.state,
10964            ModuleState::Failed,
10965            "Failed was visible before its terminal record could be written"
10966        );
10967        assert!(
10968            before_status.process_alive,
10969            "process death was visible before its terminal record could be written"
10970        );
10971        let failed = module.status().unwrap();
10972        assert_eq!(failed.state, ModuleState::Failed);
10973        assert!(!failed.process_alive);
10974        let history = module.terminal_history();
10975        assert_eq!(history.entries.len(), 1);
10976        assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
10977        assert_eq!(
10978            history.entries[0].disposition_detail.as_deref(),
10979            Some("crash budget exhausted: max_restarts=0 within window_secs=600")
10980        );
10981    }
10982
10983    #[test]
10984    fn observed_exits_publish_process_death_after_terminal_history() {
10985        use std::io::{Read, Write};
10986        for (kind, enabled, shutdown, wait_error, expected_state) in [
10987            (ExitKind::Clean, true, false, false, ModuleState::Stopped),
10988            (ExitKind::Crash, true, false, false, ModuleState::Restarting),
10989            (ExitKind::Crash, false, false, false, ModuleState::Disabled),
10990            (
10991                ExitKind::DeliberateSeverance,
10992                true,
10993                false,
10994                false,
10995                ModuleState::Restarting,
10996            ),
10997            #[cfg(unix)]
10998            (ExitKind::Crash, true, true, false, ModuleState::Stopped),
10999            (ExitKind::Crash, true, false, true, ModuleState::Failed),
11000        ] {
11001            let supervisor = Supervisor::new_for_test(
11002                Arc::new(Registry::default()),
11003                RestartPolicy::new(1, Duration::ZERO),
11004            );
11005            let runtime = supervisor.runtime_config();
11006            #[cfg(unix)]
11007            if shutdown {
11008                runtime.child_roster.close();
11009            }
11010            #[cfg(not(unix))]
11011            let _ = shutdown;
11012            let spec = windowed_crash_spec("exit-publication-order");
11013            let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(
11014                ModuleState::Running,
11015                enabled,
11016            )));
11017            lock_snapshot(&snapshot).unwrap().process_alive = true;
11018            let reaped_snapshot = snapshot.clone();
11019            let history = runtime.terminal_ring.clone();
11020            let mut barrier = terminal_record_barrier(&runtime.spawn_events);
11021            let reap = std::thread::spawn(move || {
11022                if wait_error {
11023                    super::fail_after_wait_error(
11024                        &spec.module_id,
11025                        &reaped_snapshot,
11026                        &runtime.terminal_ring,
11027                        &runtime.spawn_events,
11028                        "wait failed".into(),
11029                    );
11030                } else {
11031                    tokio::runtime::Builder::new_current_thread()
11032                        .enable_all()
11033                        .build()
11034                        .unwrap()
11035                        .block_on(on_child_exit(
11036                            &spec,
11037                            runtime.restart_policy,
11038                            &supervisor.registry,
11039                            &reaped_snapshot,
11040                            &runtime.terminal_ring,
11041                            &runtime.spawn_events,
11042                            &runtime.child_roster,
11043                            ExitReport {
11044                                kind,
11045                                code: Some(0),
11046                                signal: None,
11047                                at_ms: 1,
11048                            },
11049                        ));
11050                }
11051            });
11052            let mut reached = [0];
11053            barrier.read_exact(&mut reached).unwrap();
11054            assert_eq!(&reached, b"R");
11055            let before_alive = lock_snapshot(&snapshot).unwrap().process_alive;
11056            let before_history = history.lock().unwrap().snapshot();
11057            barrier.write_all(b"X").unwrap();
11058            reap.join().unwrap();
11059            assert!(
11060                before_alive,
11061                "{kind:?}: death was published before recording"
11062            );
11063            assert!(before_history.entries.is_empty());
11064            assert_eq!(lock_snapshot(&snapshot).unwrap().state, expected_state);
11065            assert!(!lock_snapshot(&snapshot).unwrap().process_alive);
11066            assert_eq!(history.lock().unwrap().snapshot().entries.len(), 1);
11067        }
11068    }
11069
11070    #[tokio::test]
11071    async fn health_restart_budget_failure_remains_failed_when_journal_append_fails() {
11072        let dir = cortexkit_test_support::ScratchDir::new("health-budget-journal-failure");
11073        let path = dir.join("terminals.jsonl");
11074        std::fs::create_dir(&path).unwrap();
11075        let supervisor = Supervisor::new_for_test(
11076            Arc::new(Registry::default()),
11077            RestartPolicy::new(0, Duration::ZERO),
11078        )
11079        .with_terminal_journal(path, "health-budget-journal-failure".into());
11080        let runtime = supervisor.runtime_config();
11081        let spec = ModuleSpec {
11082            program: fake_aft_stub_path(),
11083            protocol: ModuleProtocol::None,
11084            env: vec![("FAKE_AFT_EXIT_CODE".into(), "17".into())],
11085            ..windowed_crash_spec("health-budget-journal-failure")
11086        };
11087        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11088        let mut child = Some(spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap());
11089        health_restart_child(
11090            &spec,
11091            &runtime,
11092            &supervisor.registry,
11093            &supervisor.process_liveness,
11094            &snapshot,
11095            &mut child,
11096            SupervisorHealthStatus::Failing,
11097            None,
11098            1,
11099        )
11100        .await
11101        .unwrap();
11102        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
11103        assert!(!lock_snapshot(&snapshot).unwrap().process_alive);
11104        let history = runtime
11105            .terminal_ring
11106            .lock()
11107            .unwrap()
11108            .durable_history(&spec.module_id);
11109        assert!(history.journal_write_failures > 0);
11110        assert_eq!(history.entries.len(), 1);
11111        assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
11112    }
11113
11114    #[test]
11115    fn health_restart_budget_failure_remains_failed_when_exit_recording_panics() {
11116        let supervisor = Supervisor::new_for_test(
11117            Arc::new(Registry::default()),
11118            RestartPolicy::new(0, Duration::ZERO),
11119        );
11120        let runtime = supervisor.runtime_config();
11121        let spec = ModuleSpec {
11122            program: fake_aft_stub_path(),
11123            protocol: ModuleProtocol::None,
11124            env: vec![("FAKE_AFT_EXIT_CODE".into(), "17".into())],
11125            ..windowed_crash_spec("health-budget-recording-panic")
11126        };
11127        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11128        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
11129            tokio::runtime::Builder::new_current_thread()
11130                .enable_all()
11131                .build()
11132                .unwrap()
11133                .block_on(async {
11134                    let mut child =
11135                        Some(spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap());
11136                    runtime.spawn_events.0.lock().unwrap().seq = u64::MAX;
11137                    health_restart_child(
11138                        &spec,
11139                        &runtime,
11140                        &supervisor.registry,
11141                        &supervisor.process_liveness,
11142                        &snapshot,
11143                        &mut child,
11144                        SupervisorHealthStatus::Failing,
11145                        None,
11146                        1,
11147                    )
11148                    .await
11149                    .unwrap();
11150                });
11151        }));
11152        let panic = result.expect_err("recording must still unwind");
11153        assert_eq!(
11154            panic.downcast_ref::<String>().map(String::as_str),
11155            Some("spawn event sequence exhausted")
11156        );
11157        let state = lock_snapshot(&snapshot).unwrap();
11158        assert_eq!(state.state, ModuleState::Failed);
11159        assert!(!state.process_alive);
11160        assert!(state.last_exit.is_some());
11161    }
11162
11163    #[tokio::test]
11164    async fn restart_budget_failure_remains_failed_when_journal_append_fails() {
11165        let dir = cortexkit_test_support::ScratchDir::new("budget-journal-failure");
11166        let path = dir.join("terminals.jsonl");
11167        std::fs::create_dir(&path).unwrap();
11168        let supervisor = Supervisor::new_for_test(
11169            Arc::new(Registry::default()),
11170            RestartPolicy::new(0, Duration::ZERO),
11171        )
11172        .with_terminal_journal(path, "budget-journal-failure".into());
11173        let runtime = supervisor.runtime_config();
11174        let spec = windowed_crash_spec("budget-journal-failure");
11175        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11176        assert!(matches!(
11177            on_child_exit(
11178                &spec,
11179                runtime.restart_policy,
11180                &supervisor.registry,
11181                &snapshot,
11182                &runtime.terminal_ring,
11183                &runtime.spawn_events,
11184                &runtime.child_roster,
11185                crash_exit_report(1),
11186            )
11187            .await,
11188            NextAction::Stop { .. }
11189        ));
11190        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
11191        let history = runtime
11192            .terminal_ring
11193            .lock()
11194            .unwrap()
11195            .durable_history(&spec.module_id);
11196        assert!(history.journal_write_failures > 0);
11197        assert_eq!(history.entries.len(), 1);
11198        assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
11199    }
11200
11201    #[test]
11202    fn restart_budget_failure_remains_failed_when_exit_recording_panics() {
11203        let supervisor = Supervisor::new_for_test(
11204            Arc::new(Registry::default()),
11205            RestartPolicy::new(0, Duration::ZERO),
11206        );
11207        let runtime = supervisor.runtime_config();
11208        let spec = windowed_crash_spec("budget-recording-panic");
11209        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11210        runtime.spawn_events.emit_spawned(&spec.module_id, 1, 1);
11211        // Exhausting the event sequence makes emit_exited panic before the
11212        // terminal write, exercising publication on the recording unwind.
11213        runtime.spawn_events.0.lock().unwrap().seq = u64::MAX;
11214        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
11215            tokio::runtime::Builder::new_current_thread()
11216                .enable_all()
11217                .build()
11218                .unwrap()
11219                .block_on(on_child_exit(
11220                    &spec,
11221                    runtime.restart_policy,
11222                    &supervisor.registry,
11223                    &snapshot,
11224                    &runtime.terminal_ring,
11225                    &runtime.spawn_events,
11226                    &runtime.child_roster,
11227                    crash_exit_report(1),
11228                ))
11229        }));
11230        let panic = result.err().expect("recording must still unwind");
11231        assert_eq!(
11232            panic.downcast_ref::<String>().map(String::as_str),
11233            Some("spawn event sequence exhausted")
11234        );
11235        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
11236    }
11237
11238    /// A stop the supervisor itself requests still stops a protocol-none
11239    /// module, even though the child answers the SIGTERM with exit 0.
11240    #[cfg(unix)]
11241    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11242    async fn protocol_none_requested_stop_and_disable_do_not_respawn() {
11243        for disable in [false, true] {
11244            let label = if disable {
11245                "none-requested-disable"
11246            } else {
11247                "none-requested-stop"
11248            };
11249            let dir = cortexkit_test_support::ScratchDir::new(label);
11250            let (spec, ready, marker) = protocol_none_sigterm_exits_clean_spec(label, dir.path());
11251            let supervisor = Supervisor::new_for_test(
11252                Arc::new(Registry::default()),
11253                RestartPolicy::new(3, Duration::ZERO),
11254            );
11255            let module = supervisor.spawn(spec).unwrap();
11256            wait_for_file(&ready).await;
11257
11258            if disable {
11259                module.set_enabled(false).await.unwrap();
11260            } else {
11261                module.stop().await.unwrap();
11262            }
11263            assert!(
11264                marker.exists(),
11265                "{label}: the child must have left through its SIGTERM handler with exit 0"
11266            );
11267
11268            // Long enough for a zero-backoff respawn to have happened if the
11269            // exit had been treated as a crash.
11270            sleep(Duration::from_millis(500)).await;
11271            let status = module.status().unwrap();
11272            let expected = if disable {
11273                ModuleState::Disabled
11274            } else {
11275                ModuleState::Stopped
11276            };
11277            assert_eq!(status.state, expected, "{label}");
11278            assert_eq!(
11279                status.spawn_generation, 1,
11280                "{label}: respawned after a requested stop"
11281            );
11282            let history = module.terminal_history();
11283            assert_eq!(history.entries.len(), 1, "{label}: {history:?}");
11284            assert_eq!(history.entries[0].exit_code, Some(0), "{label}");
11285            assert_ne!(
11286                history.entries[0].disposition,
11287                TerminalDisposition::Restarting,
11288                "{label}"
11289            );
11290        }
11291    }
11292
11293    /// A subc-wire module that exits 0 on its own is still a stop: the
11294    /// protocol-none rule must not reach it.
11295    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11296    async fn subc_wire_clean_exit_is_still_a_stop() {
11297        let supervisor = Supervisor::new_for_test(
11298            Arc::new(Registry::default()),
11299            RestartPolicy::new(3, Duration::ZERO),
11300        );
11301        let module = supervisor
11302            .spawn(ModuleSpec {
11303                module_id: "wire-clean-exit".to_string(),
11304                program: fake_aft_stub_path(),
11305                args: Vec::new(),
11306                env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "0".to_string())],
11307                reserved: false,
11308                reserved_prefixes: Vec::new(),
11309                protocol: ModuleProtocol::Subc,
11310                overlap: Default::default(),
11311            })
11312            .unwrap();
11313
11314        let deadline = Instant::now() + Duration::from_secs(10);
11315        while module.terminal_history().entries.is_empty() {
11316            assert!(Instant::now() < deadline, "module never exited");
11317            sleep(Duration::from_millis(10)).await;
11318        }
11319        // Long enough for a zero-backoff respawn to have happened.
11320        sleep(Duration::from_millis(500)).await;
11321        let status = module.status().unwrap();
11322        assert_eq!(status.state, ModuleState::Stopped);
11323        assert_eq!(status.spawn_generation, 1);
11324        let history = module.terminal_history();
11325        assert_eq!(history.entries.len(), 1, "{history:?}");
11326        assert_eq!(history.entries[0].exit_code, Some(0));
11327        assert_eq!(history.entries[0].disposition, TerminalDisposition::Stopped);
11328    }
11329
11330    #[cfg(unix)]
11331    #[tokio::test]
11332    async fn reload_exit_keeps_roster_until_terminal_is_recorded_during_shutdown() {
11333        let dir = cortexkit_test_support::ScratchDir::new("reload-roster-terminal-order");
11334        let record = dir.join("live-children.json");
11335        let supervisor = Supervisor::new_for_test(
11336            Arc::new(Registry::default()),
11337            RestartPolicy::new(0, Duration::ZERO),
11338        );
11339        let mut runtime = supervisor.runtime_config();
11340        runtime.child_roster.record_to(record.clone());
11341        let gate = Arc::new(super::ReloadExitRecordGate::default());
11342        runtime.test_reload_exit_record_gate = Some(Arc::clone(&gate));
11343        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11344        let spec = ModuleSpec {
11345            module_id: "reload-exit-roster".into(),
11346            program: fake_aft_stub_path(),
11347            args: Vec::new(),
11348            env: vec![("FAKE_AFT_EXIT_CODE".into(), "23".into())],
11349            reserved: false,
11350            reserved_prefixes: Vec::new(),
11351            protocol: ModuleProtocol::Subc,
11352            overlap: Default::default(),
11353        };
11354        let mut child = None;
11355        let reload = super::finish_reload_child(
11356            &spec,
11357            &runtime,
11358            &supervisor.registry,
11359            &supervisor.process_liveness,
11360            &snapshot,
11361            &mut child,
11362        );
11363        tokio::pin!(reload);
11364        tokio::select! {
11365            result = &mut reload => panic!("reload missed the before-record gate: {result:?}"),
11366            _ = gate.reached.notified() => {}
11367        }
11368        assert!(runtime
11369            .terminal_ring
11370            .lock()
11371            .unwrap()
11372            .snapshot()
11373            .entries
11374            .is_empty());
11375        assert_eq!(
11376            crate::live_children::read_record(&record).unwrap().len(),
11377            1,
11378            "shutdown must still wait for the reaped child until its terminal record exists"
11379        );
11380        runtime.child_roster.close();
11381        gate.resume.notify_one();
11382        assert!(reload.await.is_err());
11383        assert!(crate::live_children::read_record(&record)
11384            .unwrap()
11385            .is_empty());
11386        let history = runtime.terminal_ring.lock().unwrap().snapshot();
11387        assert_eq!(history.entries.len(), 1);
11388        assert_eq!(
11389            history.entries[0].disposition,
11390            TerminalDisposition::DaemonShutdown
11391        );
11392    }
11393
11394    /// Each restart-producing arm has its own state transition. Keeping their
11395    /// lifetime count assertions adjacent prevents a later new arm from silently
11396    /// spending budget without recording the historical restart.
11397    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11398    async fn every_restart_increment_path_advances_lifetime_count() {
11399        let supervisor = Supervisor::new_for_test(
11400            Arc::new(Registry::default()),
11401            RestartPolicy::new(1, Duration::ZERO),
11402        );
11403        let runtime = supervisor.runtime_config();
11404        let spec = ModuleSpec {
11405            module_id: "lifetime-increment-path".to_string(),
11406            program: PathBuf::from("/unused/lifetime-increment-path"),
11407            args: Vec::new(),
11408            env: Vec::new(),
11409            reserved: false,
11410            reserved_prefixes: Vec::new(),
11411            protocol: ModuleProtocol::Subc,
11412            overlap: Default::default(),
11413        };
11414
11415        let crash_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11416        assert!(matches!(
11417            on_child_exit(
11418                &spec,
11419                runtime.restart_policy,
11420                &supervisor.registry,
11421                &crash_snapshot,
11422                &runtime.terminal_ring,
11423                &runtime.spawn_events,
11424                &runtime.child_roster,
11425                ExitReport {
11426                    kind: ExitKind::Crash,
11427                    code: Some(1),
11428                    signal: None,
11429                    at_ms: 1,
11430                },
11431            )
11432            .await,
11433            NextAction::Restart { schedule: _ }
11434        ));
11435        let (crash_restarts, crash_lifetime) = {
11436            let state = lock_snapshot(&crash_snapshot).unwrap();
11437            (state.crash_restarts.len(), state.lifetime_restarts)
11438        };
11439        assert_eq!(crash_restarts, 1);
11440        assert_eq!(crash_lifetime, 1);
11441
11442        let health_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11443        let mut health_child = None;
11444        assert!(matches!(
11445            health_restart_child(
11446                &spec,
11447                &runtime,
11448                &supervisor.registry,
11449                &supervisor.process_liveness,
11450                &health_snapshot,
11451                &mut health_child,
11452                SupervisorHealthStatus::Failing,
11453                None,
11454                2,
11455            )
11456            .await,
11457            Ok(())
11458        ));
11459        assert!(health_child.is_none());
11460        assert!(lock_snapshot(&health_snapshot).unwrap().respawn_pending);
11461        assert!(runtime.scheduled_respawn.lock().unwrap().take().is_some());
11462        let (health_restarts, health_lifetime) = {
11463            let state = lock_snapshot(&health_snapshot).unwrap();
11464            (state.crash_restarts.len(), state.lifetime_restarts)
11465        };
11466        assert_eq!(health_restarts, 1);
11467        assert_eq!(health_lifetime, 1);
11468
11469        let reload_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11470        let mut reload_child = None;
11471        assert!(matches!(
11472            handle_reload_spawn_failure(
11473                &spec,
11474                &runtime,
11475                &supervisor.process_liveness,
11476                &reload_snapshot,
11477                &mut reload_child,
11478                "forced reload spawn failure".to_string(),
11479            )
11480            .await,
11481            Err(SuperviseError::ReloadFailed { .. })
11482        ));
11483        let (reload_restarts, reload_lifetime) = {
11484            let state = lock_snapshot(&reload_snapshot).unwrap();
11485            (state.crash_restarts.len(), state.lifetime_restarts)
11486        };
11487        assert_eq!(reload_restarts, 1);
11488        assert_eq!(reload_lifetime, 1);
11489    }
11490
11491    #[tokio::test]
11492    async fn deliberately_severed_live_child_records_lifetime_without_spending_restart_budget() {
11493        let supervisor = Supervisor::new_for_test(
11494            Arc::new(Registry::default()),
11495            RestartPolicy::new(3, Duration::ZERO),
11496        );
11497        let runtime = supervisor.runtime_config();
11498        let spec = ModuleSpec {
11499            module_id: "deliberately-severed".to_string(),
11500            program: PathBuf::from("/unused/deliberately-severed"),
11501            args: Vec::new(),
11502            env: Vec::new(),
11503            reserved: false,
11504            reserved_prefixes: Vec::new(),
11505            protocol: ModuleProtocol::Subc,
11506            overlap: Default::default(),
11507        };
11508        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11509        let process = ProcessIdentity {
11510            pid: 41,
11511            start_time: 101,
11512        };
11513        record_deliberate_severance(&snapshot, process).unwrap();
11514        let exit_report = apply_deliberate_severance_marker(
11515            &snapshot,
11516            Some(process),
11517            ExitReport {
11518                kind: ExitKind::Crash,
11519                code: Some(1),
11520                signal: None,
11521                at_ms: 1,
11522            },
11523        );
11524        assert_eq!(exit_report.kind, ExitKind::DeliberateSeverance);
11525
11526        assert!(matches!(
11527            on_child_exit(
11528                &spec,
11529                runtime.restart_policy,
11530                &supervisor.registry,
11531                &snapshot,
11532                &runtime.terminal_ring,
11533                &runtime.spawn_events,
11534                &runtime.child_roster,
11535                exit_report,
11536            )
11537            .await,
11538            NextAction::Restart { schedule: _ }
11539        ));
11540        let state = lock_snapshot(&snapshot).unwrap();
11541        assert_eq!(state.lifetime_restarts, 1);
11542        assert_eq!(state.crash_restarts.len(), 0);
11543    }
11544
11545    #[tokio::test]
11546    async fn genuine_crash_spends_restart_budget_and_records_lifetime() {
11547        let supervisor = Supervisor::new_for_test(
11548            Arc::new(Registry::default()),
11549            RestartPolicy::new(3, Duration::ZERO),
11550        );
11551        let runtime = supervisor.runtime_config();
11552        let spec = ModuleSpec {
11553            module_id: "genuine-crash".to_string(),
11554            program: PathBuf::from("/unused/genuine-crash"),
11555            args: Vec::new(),
11556            env: Vec::new(),
11557            reserved: false,
11558            reserved_prefixes: Vec::new(),
11559            protocol: ModuleProtocol::Subc,
11560            overlap: Default::default(),
11561        };
11562        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11563
11564        assert!(matches!(
11565            on_child_exit(
11566                &spec,
11567                runtime.restart_policy,
11568                &supervisor.registry,
11569                &snapshot,
11570                &runtime.terminal_ring,
11571                &runtime.spawn_events,
11572                &runtime.child_roster,
11573                ExitReport {
11574                    kind: ExitKind::Crash,
11575                    code: Some(1),
11576                    signal: None,
11577                    at_ms: 1,
11578                },
11579            )
11580            .await,
11581            NextAction::Restart { schedule: _ }
11582        ));
11583        let state = lock_snapshot(&snapshot).unwrap();
11584        assert_eq!(state.lifetime_restarts, 1);
11585        assert_eq!(state.crash_restarts.len(), 1);
11586    }
11587
11588    fn crash_exit_report(at_ms: u64) -> ExitReport {
11589        ExitReport {
11590            kind: ExitKind::Crash,
11591            code: Some(1),
11592            signal: None,
11593            at_ms,
11594        }
11595    }
11596
11597    fn windowed_crash_spec(module_id: &str) -> ModuleSpec {
11598        ModuleSpec {
11599            module_id: module_id.to_string(),
11600            program: PathBuf::from("/unused").join(module_id),
11601            args: Vec::new(),
11602            env: Vec::new(),
11603            reserved: false,
11604            reserved_prefixes: Vec::new(),
11605            protocol: ModuleProtocol::Subc,
11606            overlap: Default::default(),
11607        }
11608    }
11609
11610    /// A real crash loop still stops. Three crashes with nothing aging out spend
11611    /// a budget of two and the third respawn is refused, and both surfaces an
11612    /// operator has -- the log line and the retained terminal record -- name the
11613    /// window rather than only the cap, because `max_restarts=2` alone is what
11614    /// this budget used to mean.
11615    #[tokio::test]
11616    async fn three_crashes_inside_the_window_stop_the_module_and_name_the_window() {
11617        let (logs, _guard) = crate::router::test_log::log_capture(tracing::Level::ERROR);
11618        let supervisor = Supervisor::new_for_test(
11619            Arc::new(Registry::default()),
11620            RestartPolicy::new(2, Duration::ZERO),
11621        );
11622        let runtime = supervisor.runtime_config();
11623        let spec = windowed_crash_spec("crash-loop-in-window");
11624        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11625
11626        for attempt in 1..=2 {
11627            assert!(
11628                matches!(
11629                    on_child_exit(
11630                        &spec,
11631                        runtime.restart_policy,
11632                        &supervisor.registry,
11633                        &snapshot,
11634                        &runtime.terminal_ring,
11635                        &runtime.spawn_events,
11636                        &runtime.child_roster,
11637                        crash_exit_report(attempt),
11638                    )
11639                    .await,
11640                    NextAction::Restart { schedule: _ }
11641                ),
11642                "crash {attempt} is inside the budget and must respawn"
11643            );
11644        }
11645
11646        assert!(matches!(
11647            on_child_exit(
11648                &spec,
11649                runtime.restart_policy,
11650                &supervisor.registry,
11651                &snapshot,
11652                &runtime.terminal_ring,
11653                &runtime.spawn_events,
11654                &runtime.child_roster,
11655                crash_exit_report(3),
11656            )
11657            .await,
11658            NextAction::Stop { .. }
11659        ));
11660
11661        {
11662            let state = lock_snapshot(&snapshot).unwrap();
11663            assert_eq!(state.state, ModuleState::Failed);
11664            assert_eq!(state.crash_restarts.len(), 2);
11665            assert_eq!(state.lifetime_restarts, 2);
11666        }
11667
11668        let history = runtime
11669            .terminal_ring
11670            .lock()
11671            .expect("terminal ring is not poisoned")
11672            .snapshot();
11673        let last = history
11674            .entries
11675            .last()
11676            .expect("the refused crash is retained");
11677        assert_eq!(last.disposition, TerminalDisposition::Failed);
11678        assert_eq!(
11679            last.disposition_detail.as_deref(),
11680            Some("crash budget exhausted: max_restarts=2 within window_secs=600")
11681        );
11682
11683        let captured = crate::router::test_log::captured_logs(&logs);
11684        assert!(
11685            captured.contains("crash budget exhausted: max_restarts=2 within window_secs=600"),
11686            "the stop must be logged with its window: {captured}"
11687        );
11688    }
11689
11690    /// The rate, stated as a test: three crashes where the first has aged past
11691    /// the window are two crashes as far as the budget is concerned, so the
11692    /// third respawn is allowed and the ring holds only the two recent ones.
11693    ///
11694    /// This is the case a lifetime counter got wrong -- and the case the daemon
11695    /// now hits routinely, since a module exits non-zero every time its
11696    /// connection to the daemon drops.
11697    #[tokio::test]
11698    async fn a_crash_older_than_the_window_frees_its_slot_for_a_later_crash() {
11699        let supervisor = Supervisor::new_for_test(
11700            Arc::new(Registry::default()),
11701            RestartPolicy::new(2, Duration::ZERO),
11702        );
11703        let runtime = supervisor.runtime_config();
11704        let spec = windowed_crash_spec("crash-across-windows");
11705        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11706
11707        for attempt in 1..=2 {
11708            assert!(matches!(
11709                on_child_exit(
11710                    &spec,
11711                    runtime.restart_policy,
11712                    &supervisor.registry,
11713                    &snapshot,
11714                    &runtime.terminal_ring,
11715                    &runtime.spawn_events,
11716                    &runtime.child_roster,
11717                    crash_exit_report(attempt),
11718                )
11719                .await,
11720                NextAction::Restart { schedule: _ }
11721            ));
11722        }
11723
11724        // The oldest crash moves out of the window; nothing else about the
11725        // module changes.
11726        update_snapshot(&snapshot, Some(&spec.module_id), |state| {
11727            age_oldest_crash_restart_out_of_window(state, runtime.restart_policy.window);
11728        })
11729        .unwrap();
11730
11731        assert!(
11732            matches!(
11733                on_child_exit(
11734                    &spec,
11735                    runtime.restart_policy,
11736                    &supervisor.registry,
11737                    &snapshot,
11738                    &runtime.terminal_ring,
11739                    &runtime.spawn_events,
11740                    &runtime.child_roster,
11741                    crash_exit_report(3),
11742                )
11743                .await,
11744                NextAction::Restart { schedule: _ }
11745            ),
11746            "a crash older than the window must not hold a budget slot"
11747        );
11748
11749        let state = lock_snapshot(&snapshot).unwrap();
11750        assert_eq!(state.state, ModuleState::Restarting);
11751        assert_eq!(
11752            state.crash_restarts.len(),
11753            2,
11754            "the aged instant is dropped and the new one takes its place"
11755        );
11756        assert_eq!(
11757            state.lifetime_restarts, 3,
11758            "the ledger counts every restart, including the ones the window forgot"
11759        );
11760    }
11761
11762    /// An operator restart hands the budget back whole, and the ledger keeps
11763    /// counting. Those are different questions -- "how close is this module to
11764    /// being stopped" and "how many times has it been replaced" -- and the
11765    /// operator action answers only the first.
11766    #[tokio::test]
11767    async fn an_operator_restart_clears_the_ring_and_leaves_the_ledger_alone() {
11768        let supervisor = Supervisor::new_for_test(
11769            Arc::new(Registry::default()),
11770            RestartPolicy::new(2, Duration::ZERO),
11771        );
11772        let runtime = supervisor.runtime_config();
11773        let spec = windowed_crash_spec("operator-cleared-budget");
11774        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11775
11776        for attempt in 1..=2 {
11777            assert!(matches!(
11778                on_child_exit(
11779                    &spec,
11780                    runtime.restart_policy,
11781                    &supervisor.registry,
11782                    &snapshot,
11783                    &runtime.terminal_ring,
11784                    &runtime.spawn_events,
11785                    &runtime.child_roster,
11786                    crash_exit_report(attempt),
11787                )
11788                .await,
11789                NextAction::Restart { schedule: _ }
11790            ));
11791        }
11792
11793        reset_restart_count(&snapshot, &spec.module_id).unwrap();
11794        {
11795            let state = lock_snapshot(&snapshot).unwrap();
11796            assert!(
11797                state.crash_restarts.is_empty(),
11798                "an operator restart returns the full budget"
11799            );
11800            assert_eq!(
11801                state.lifetime_restarts, 2,
11802                "clearing the budget must not unmake the crashes"
11803            );
11804        }
11805
11806        assert!(
11807            matches!(
11808                on_child_exit(
11809                    &spec,
11810                    runtime.restart_policy,
11811                    &supervisor.registry,
11812                    &snapshot,
11813                    &runtime.terminal_ring,
11814                    &runtime.spawn_events,
11815                    &runtime.child_roster,
11816                    crash_exit_report(3),
11817                )
11818                .await,
11819                NextAction::Restart { schedule: _ }
11820            ),
11821            "the cleared budget must be spendable again"
11822        );
11823        let state = lock_snapshot(&snapshot).unwrap();
11824        assert_eq!(state.crash_restarts.len(), 1);
11825        assert_eq!(state.lifetime_restarts, 3);
11826    }
11827
11828    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11829    async fn severance_marker_for_a_dead_child_does_not_label_its_successor() {
11830        let severed = ProcessIdentity {
11831            pid: 41,
11832            start_time: 101,
11833        };
11834        let successor = ProcessIdentity {
11835            pid: 41,
11836            start_time: 202,
11837        };
11838        let module = module_with_recovery_snapshot(ModuleState::Running, true, 0);
11839        update_snapshot(&module.inner.snapshot, Some("recovery-snapshot"), |state| {
11840            state.pid = Some(successor.pid);
11841            state.process_start_time = Some(successor.start_time);
11842        })
11843        .unwrap();
11844        assert!(!module.record_deliberate_severance(severed).unwrap());
11845
11846        let exit_report = apply_deliberate_severance_marker(
11847            &module.inner.snapshot,
11848            Some(successor),
11849            ExitReport {
11850                kind: ExitKind::Crash,
11851                code: Some(1),
11852                signal: None,
11853                at_ms: 1,
11854            },
11855        );
11856
11857        assert_eq!(exit_report.kind, ExitKind::Crash);
11858    }
11859
11860    #[tokio::test]
11861    async fn drain_reap_marks_deliberate_severance_and_records_lifetime_without_budget() {
11862        let registry = Registry::default();
11863        let supervisor = Supervisor::new_for_test(
11864            Arc::new(Registry::default()),
11865            RestartPolicy::new(3, Duration::ZERO),
11866        );
11867        let runtime = supervisor.runtime_config();
11868        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11869        let spec = ModuleSpec {
11870            module_id: "drain-deliberate-severance".to_string(),
11871            program: fake_aft_stub_path(),
11872            args: Vec::new(),
11873            env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
11874            reserved: false,
11875            reserved_prefixes: Vec::new(),
11876            protocol: ModuleProtocol::Subc,
11877            overlap: Default::default(),
11878        };
11879        let mut child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
11880        let process = ProcessIdentity {
11881            pid: 41,
11882            start_time: 101,
11883        };
11884        child.process_identity = Some(process);
11885        update_snapshot(&snapshot, Some(&spec.module_id), |state| {
11886            state.pid = Some(process.pid);
11887            state.process_start_time = Some(process.start_time);
11888        })
11889        .unwrap();
11890        record_deliberate_severance(&snapshot, process).unwrap();
11891
11892        drain_child_to_state(
11893            &spec.module_id,
11894            spec.protocol,
11895            // The child exits on its own; no signal may change the exit this
11896            // test classifies.
11897            StopNotice::SentOverConnection,
11898            &registry,
11899            None,
11900            &snapshot,
11901            &runtime.terminal_ring,
11902            &runtime.spawn_events,
11903            child,
11904            Duration::from_secs(1),
11905            ModuleState::Stopped,
11906            Some(false),
11907        )
11908        .await
11909        .unwrap();
11910
11911        let state = lock_snapshot(&snapshot).unwrap();
11912        assert_eq!(
11913            state.last_exit.as_ref().map(|exit| exit.kind),
11914            Some(ExitKind::DeliberateSeverance)
11915        );
11916        assert_eq!(state.lifetime_restarts, 1);
11917        assert_eq!(state.crash_restarts.len(), 0);
11918        drop(state);
11919        let history = runtime.terminal_ring.lock().unwrap().snapshot();
11920        assert_eq!(
11921            history.entries[0].exit_kind,
11922            subc_control::TerminalExitKind::DeliberateSeverance
11923        );
11924    }
11925
11926    #[tokio::test]
11927    async fn ordinary_drain_reap_does_not_record_a_lifetime_restart() {
11928        let registry = Registry::default();
11929        let supervisor = Supervisor::new_for_test(
11930            Arc::new(Registry::default()),
11931            RestartPolicy::new(3, Duration::ZERO),
11932        );
11933        let runtime = supervisor.runtime_config();
11934        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11935        let spec = ModuleSpec {
11936            module_id: "ordinary-drain".to_string(),
11937            program: fake_aft_stub_path(),
11938            args: Vec::new(),
11939            env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
11940            reserved: false,
11941            reserved_prefixes: Vec::new(),
11942            protocol: ModuleProtocol::Subc,
11943            overlap: Default::default(),
11944        };
11945        let child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
11946
11947        drain_child_to_state(
11948            &spec.module_id,
11949            spec.protocol,
11950            // The child exits on its own; no signal may change the exit this
11951            // test classifies.
11952            StopNotice::SentOverConnection,
11953            &registry,
11954            None,
11955            &snapshot,
11956            &runtime.terminal_ring,
11957            &runtime.spawn_events,
11958            child,
11959            Duration::from_secs(1),
11960            ModuleState::Stopped,
11961            Some(false),
11962        )
11963        .await
11964        .unwrap();
11965
11966        let state = lock_snapshot(&snapshot).unwrap();
11967        assert_eq!(
11968            state.last_exit.as_ref().map(|exit| exit.kind),
11969            Some(ExitKind::Crash)
11970        );
11971        assert_eq!(state.lifetime_restarts, 0);
11972        assert_eq!(state.crash_restarts.len(), 0);
11973    }
11974
11975    #[test]
11976    fn fatal_connection_teardown_cannot_arm_a_marker_for_a_surviving_process() {
11977        // The server's generic fatal-routing branch only knows that the
11978        // connection failed; it does not know that the daemon deliberately
11979        // initiated a process-killing severance. Keep this seam explicit so a
11980        // future connection error path cannot silently reintroduce the stale
11981        // exemption that mislabels a later genuine crash.
11982        assert!(!include_str!("server.rs")
11983            .contains("router.record_deliberate_connection_severance(ctx.connection_id)"));
11984    }
11985
11986    /// The `route.closed` `drained` value must be the quiescence wait's own
11987    /// measurement (`Ok`), never invented -- except on `Err`, where there is no
11988    /// measurement at all and `false` is the one honest constant. This is the exact
11989    /// logic `begin_forwarding_drain_with` now applies before sending `route.closed`
11990    /// on every return path, including the one that used to return early via `?`
11991    /// with `route.closing` already sent and no `route.closed` ever following.
11992    #[test]
11993    fn drained_after_quiescence_wait_passes_ok_through_and_forces_false_on_err() {
11994        assert!(drained_after_quiescence_wait(&Ok(true)));
11995        assert!(!drained_after_quiescence_wait(&Ok(false)));
11996        assert!(!drained_after_quiescence_wait(&Err(
11997            SuperviseError::StatePoisoned { module_id: None }
11998        )));
11999    }
12000
12001    /// `supervise_loop`'s `wait()`-error arm now calls `record_terminal` like every
12002    /// other exit path does, so a module whose child `wait()` itself errored (e.g.
12003    /// already reaped out-of-band) still leaves a terminal record rather than none
12004    /// at all. Triggering the real `wait()` I/O error from an integration test would
12005    /// need a genuine already-reaped-child race, which is OS-specific and not
12006    /// something this suite attempts elsewhere; this test instead verifies the
12007    /// record produced for that arm end-to-end through the real `TerminalRing`, and
12008    /// the call site itself is verified by inspection to sit in that exact arm.
12009    #[test]
12010    fn wait_error_exit_report_records_a_failed_terminal_with_no_code_or_signal() {
12011        let ring = Arc::new(Mutex::new(TerminalRing::new(
12012            TerminalRingConfig::default(),
12013            0,
12014        )));
12015        record_wait_error_terminal("wait-error", &ring, &super::SpawnEventFeed::default());
12016
12017        let snapshot = ring.lock().unwrap().snapshot();
12018        assert_eq!(snapshot.entries.len(), 1);
12019        let entry = &snapshot.entries[0];
12020        assert_eq!(entry.exit_code, None);
12021        assert_eq!(entry.exit_signal, None);
12022        assert_eq!(entry.disposition, TerminalDisposition::Failed);
12023    }
12024
12025    #[test]
12026    fn wait_error_exit_path_preserves_spawn_event_density() {
12027        let feed = super::SpawnEventFeed::default();
12028        feed.configure_incarnation("wait-error-density".to_string());
12029        feed.emit_spawned("wait-error", 41, 1);
12030        let ring = Arc::new(Mutex::new(TerminalRing::new(
12031            TerminalRingConfig::default(),
12032            0,
12033        )));
12034
12035        record_wait_error_terminal("wait-error", &ring, &feed);
12036        feed.emit_spawned("after-wait-error", 42, 2);
12037
12038        let state = feed.0.lock().unwrap();
12039        let sequences = state
12040            .events
12041            .iter()
12042            .map(|event| event.cursor.seq)
12043            .collect::<Vec<_>>();
12044        assert_eq!(sequences, vec![1, 2, 3]);
12045        assert_eq!(state.events[1].kind, SpawnEventKind::Exited);
12046        assert_eq!(state.events[1].exit_code, None);
12047        assert_eq!(state.events[1].exit_signal, None);
12048    }
12049
12050    /// Pins the report's `kind` too: the wait-error arm treats an unwaitable child
12051    /// as a crash (matching `fail_snapshot`'s `Failed` disposition for this arm),
12052    /// not a clean exit it never actually observed.
12053    #[test]
12054    fn wait_error_exit_report_is_classified_as_a_crash() {
12055        assert_eq!(wait_error_exit_report().kind, ExitKind::Crash);
12056    }
12057}
12058
12059#[cfg(all(test, unix))]
12060mod health_event_tests {
12061    use super::*;
12062    use cortexkit_test_support::ScratchDir;
12063    use subc_protocol::{manifest::Concurrency, session::ModuleControlResponse};
12064
12065    struct Actor {
12066        module: SupervisedModule,
12067        registry: Arc<Registry>,
12068        forwarding: Arc<ForwardingTable>,
12069        spec: ModuleSpec,
12070        health: HealthConfig,
12071        rx: mpsc::Receiver<crate::router::OutboundFrame>,
12072        _home: ScratchDir,
12073    }
12074
12075    async fn wait_for<T>(reason: &str, mut observe: impl FnMut() -> Option<T>) -> T {
12076        // OS exec and socket readiness are real I/O. Stay runnable while waiting
12077        // for an observed condition, rather than letting paused time auto-advance
12078        // deadlines or assuming a fixed number of yields finishes the work.
12079        let deadline = std::time::Instant::now() + Duration::from_secs(10);
12080        let tick = Instant::now();
12081        loop {
12082            if let Some(value) = observe() {
12083                assert_eq!(Instant::now(), tick, "{reason} must not wait for a timer");
12084                return value;
12085            }
12086            assert!(
12087                std::time::Instant::now() < deadline,
12088                "timed out waiting for {reason}"
12089            );
12090            tokio::task::yield_now().await;
12091        }
12092    }
12093
12094    impl Actor {
12095        async fn start(protocol: ModuleProtocol, cadence: Duration) -> Self {
12096            let home = ScratchDir::new("health-events");
12097            let registry = Arc::new(Registry::default());
12098            let forwarding = Arc::new(ForwardingTable::default());
12099            let health = HealthConfig {
12100                cadence,
12101                deadline: Duration::from_secs(3600),
12102                ..HealthConfig::default()
12103            };
12104            let supervisor =
12105                Supervisor::new_for_test(registry.clone(), RestartPolicy::new(3, Duration::ZERO))
12106                    .with_forwarding(forwarding.clone())
12107                    .with_health_config(health.clone());
12108            let spec = ModuleSpec {
12109                module_id: "event-health-child".into(),
12110                program: PathBuf::from("/bin/sh"),
12111                // The process stays alive but has no real bus peer. Registrations
12112                // below exercise the same registry writes as accepted HELLOs.
12113                args: vec!["-c".into(), "exec sleep 600".into()],
12114                env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
12115                    .into_iter()
12116                    .map(|key| (key.into(), home.path().display().to_string()))
12117                    .collect(),
12118                reserved: false,
12119                reserved_prefixes: vec![],
12120                protocol,
12121                overlap: Default::default(),
12122            };
12123            let module = supervisor.spawn(spec.clone()).unwrap();
12124            let (_, rx) = mpsc::channel(8);
12125            let actor = Self {
12126                module,
12127                registry,
12128                forwarding,
12129                spec,
12130                health,
12131                rx,
12132                _home: home,
12133            };
12134            actor
12135                .wait_for_select(|checkpoint| checkpoint.generation > 0)
12136                .await;
12137            actor
12138        }
12139
12140        fn turns(&self) -> u64 {
12141            lock_snapshot(&self.module.inner.snapshot)
12142                .unwrap()
12143                .actor_turns
12144        }
12145
12146        async fn wait_for_select(
12147            &self,
12148            expected: impl Fn(&ActorSelectCheckpoint) -> bool,
12149        ) -> ActorSelectCheckpoint {
12150            wait_for("actor select checkpoint", || {
12151                let state = lock_snapshot(&self.module.inner.snapshot).unwrap();
12152                state.actor_select.filter(|checkpoint| {
12153                    checkpoint.generation == state.spawn_generation
12154                        && state.state == ModuleState::Running
12155                        && state.process_alive
12156                        && state.reported_pid().is_some()
12157                        && expected(checkpoint)
12158                })
12159            })
12160            .await
12161        }
12162
12163        fn hello(&mut self, connection: u64, health: bool) {
12164            let manifest =
12165                subc_protocol::manifest::ModuleManifest::builder(&self.spec.module_id, "1.0.0")
12166                    .protocol_ver(subc_protocol::PROTOCOL_VERSION)
12167                    .build();
12168            let connection = ConnectionId::new(connection);
12169            let (tx, rx) = mpsc::channel(8);
12170            self.rx = rx;
12171            self.forwarding
12172                .register_module_connection(
12173                    connection,
12174                    self.spec.module_id.clone(),
12175                    subc_protocol::PROTOCOL_VERSION,
12176                    Concurrency::ModuleManaged,
12177                    FrameSink::new(tx),
12178                )
12179                .unwrap();
12180            self.registry
12181                .register_with_control_ops(
12182                    manifest,
12183                    subc_protocol::PROTOCOL_VERSION,
12184                    connection,
12185                    if health {
12186                        vec![MODULE_CONTROL_OP_HEALTH_CHECK.into()]
12187                    } else {
12188                        vec![]
12189                    },
12190                )
12191                .unwrap();
12192        }
12193
12194        async fn probe(&mut self) -> crate::router::OutboundFrame {
12195            let frame = wait_for("outbound health probe", || match self.rx.try_recv() {
12196                Ok(frame) => Some(frame),
12197                Err(mpsc::error::TryRecvError::Empty) => None,
12198                Err(mpsc::error::TryRecvError::Disconnected) => panic!("health peer disconnected"),
12199            })
12200            .await;
12201            let body: Value = serde_json::from_slice(&frame.body).unwrap();
12202            assert_eq!(body["op"], MODULE_CONTROL_OP_HEALTH_CHECK);
12203            frame
12204        }
12205
12206        fn no_probe(&mut self) {
12207            assert!(matches!(
12208                self.rx.try_recv(),
12209                Err(mpsc::error::TryRecvError::Empty)
12210            ));
12211        }
12212
12213        fn answer(&self, connection: u64, frame: crate::router::OutboundFrame) {
12214            self.forwarding
12215                .complete_module_control_rpc(
12216                    ConnectionId::new(connection),
12217                    frame.header.corr,
12218                    Some(MODULE_CONTROL_OP_HEALTH_CHECK),
12219                    ModuleControlRpcOutcome::Response(ModuleControlResponse::HealthCheck {
12220                        status: HealthStatus::Ok,
12221                        detail: None,
12222                        metrics: None,
12223                    }),
12224                )
12225                .unwrap();
12226        }
12227    }
12228
12229    #[tokio::test(start_paused = true)]
12230    async fn no_health_children_park_for_sixty_seconds() {
12231        let none = Actor::start(ModuleProtocol::None, Duration::from_secs(30)).await;
12232        let unregistered = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12233        let mut unadvertised = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12234        unadvertised.hello(1, false);
12235        // Include any one-off registration wake in the budget; receiving a
12236        // no-health HELLO must not turn a parked child into a polling child.
12237        // Advance in 10 ms steps so the old poll really executes ~6,000 turns;
12238        // a single 60 s jump would only observe one expired timer.
12239        for _ in 0..6000 {
12240            tokio::time::advance(Duration::from_millis(10)).await;
12241            tokio::task::yield_now().await;
12242        }
12243        for actor in [&none, &unregistered, &unadvertised] {
12244            assert!(
12245                actor.turns() <= 5,
12246                "no-health actor woke {} times",
12247                actor.turns()
12248            );
12249            assert_eq!(actor.module.state().unwrap(), ModuleState::Running);
12250        }
12251    }
12252
12253    #[tokio::test(start_paused = true)]
12254    async fn late_hello_starts_a_due_probe_in_the_notification_tick() {
12255        // With a zero cadence the first probe is due at once, so this measures
12256        // only how fast the registration notification starts health probing,
12257        // not the normal 30-33 s wait before a first probe (checked below).
12258        let mut actor = Actor::start(ModuleProtocol::Subc, Duration::ZERO).await;
12259        let tick = Instant::now();
12260        actor.hello(1, true);
12261        actor.probe().await;
12262        assert_eq!(Instant::now(), tick, "HELLO must not wait for a poll");
12263    }
12264
12265    #[tokio::test(start_paused = true)]
12266    async fn hello_keeps_the_jittered_cadence_and_catalog_updates_do_not_reset_it() {
12267        let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12268        let hello_tick = Instant::now();
12269        actor.hello(1, true);
12270        let armed = actor
12271            .wait_for_select(|checkpoint| {
12272                checkpoint.registered_connection == Some(ConnectionId::new(1))
12273                    && checkpoint.next_probe_at.is_some()
12274            })
12275            .await;
12276        let delay = jittered_health_delay(&actor.spec.module_id, 0, actor.health.cadence);
12277        assert!((Duration::from_secs(30)..Duration::from_secs(33)).contains(&delay));
12278        let first_deadline = hello_tick + delay;
12279        assert_eq!(armed.next_probe_at, Some(first_deadline));
12280        tokio::time::advance(Duration::from_secs(29)).await;
12281        let turns = actor.turns();
12282        actor
12283            .registry
12284            .replace_catalog_for_connection(ConnectionId::new(1), vec![], None, Some(true))
12285            .unwrap();
12286        let updated = actor
12287            .wait_for_select(|checkpoint| checkpoint.turn > turns)
12288            .await;
12289        assert_eq!(
12290            updated.next_probe_at,
12291            Some(first_deadline),
12292            "catalog update must not reset cadence"
12293        );
12294        actor.no_probe();
12295        tokio::time::advance(first_deadline - Instant::now()).await;
12296        let frame = actor.probe().await;
12297        actor.answer(1, frame);
12298        let next = jittered_health_delay(&actor.spec.module_id, 1, actor.health.cadence);
12299        let next_deadline = Instant::now() + next;
12300        let rearmed = actor
12301            .wait_for_select(|checkpoint| {
12302                checkpoint
12303                    .next_probe_at
12304                    .is_some_and(|deadline| deadline > first_deadline)
12305            })
12306            .await;
12307        assert_eq!(rearmed.next_probe_at, Some(next_deadline));
12308        tokio::time::advance(next - Duration::from_nanos(1)).await;
12309        actor.no_probe();
12310        tokio::time::advance(Duration::from_nanos(1)).await;
12311        actor.probe().await;
12312    }
12313
12314    #[tokio::test(start_paused = true)]
12315    async fn disconnect_disarms_and_reconnect_rearms_health() {
12316        let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12317        actor.hello(1, true);
12318        actor
12319            .wait_for_select(|checkpoint| {
12320                checkpoint.registered_connection == Some(ConnectionId::new(1))
12321                    && checkpoint.next_probe_at.is_some()
12322            })
12323            .await;
12324        let turns = actor.turns();
12325        let tick = Instant::now();
12326        actor
12327            .registry
12328            .deregister_connection(ConnectionId::new(1))
12329            .unwrap();
12330        let disarmed = actor
12331            .wait_for_select(|checkpoint| {
12332                checkpoint.turn > turns && checkpoint.registered_connection.is_none()
12333            })
12334            .await;
12335        assert_eq!(disarmed.next_probe_at, None);
12336        assert_eq!(disarmed.wake_after, None);
12337        assert_eq!(
12338            Instant::now(),
12339            tick,
12340            "disconnect must disarm in the notification tick"
12341        );
12342        tokio::time::advance(Duration::from_secs(60)).await;
12343        actor.no_probe();
12344        let reconnect_tick = Instant::now();
12345        actor.hello(2, true);
12346        let rearmed = actor
12347            .wait_for_select(|checkpoint| {
12348                checkpoint.registered_connection == Some(ConnectionId::new(2))
12349                    && checkpoint.next_probe_at.is_some()
12350            })
12351            .await;
12352        let delay = jittered_health_delay(&actor.spec.module_id, 0, actor.health.cadence);
12353        assert_eq!(rearmed.next_probe_at, Some(reconnect_tick + delay));
12354        tokio::time::advance(delay).await;
12355        actor.probe().await;
12356    }
12357
12358    #[tokio::test(start_paused = true)]
12359    async fn reregistration_without_health_disarms_the_old_schedule() {
12360        let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12361        actor.hello(1, true);
12362        actor
12363            .wait_for_select(|checkpoint| {
12364                checkpoint.registered_connection == Some(ConnectionId::new(1))
12365                    && checkpoint.next_probe_at.is_some()
12366            })
12367            .await;
12368        actor
12369            .registry
12370            .deregister_connection(ConnectionId::new(1))
12371            .unwrap();
12372        actor.hello(2, false);
12373        let disarmed = actor
12374            .wait_for_select(|checkpoint| {
12375                checkpoint.registered_connection == Some(ConnectionId::new(2))
12376            })
12377            .await;
12378        assert_eq!(disarmed.next_probe_at, None);
12379        assert_eq!(disarmed.wake_after, None);
12380        tokio::time::advance(Duration::from_secs(60)).await;
12381        actor.no_probe();
12382    }
12383
12384    #[tokio::test(start_paused = true)]
12385    async fn restart_waits_for_its_own_hello_before_rearming_health() {
12386        let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12387        actor.hello(1, true);
12388        let armed = actor
12389            .wait_for_select(|checkpoint| {
12390                checkpoint.registered_connection == Some(ConnectionId::new(1))
12391                    && checkpoint.next_probe_at.is_some()
12392            })
12393            .await;
12394        // Simulate the old process's connection closing before the restart
12395        // tears the process down. The replacement process has started but has
12396        // not sent its HELLO, so it is not registered.
12397        actor
12398            .registry
12399            .deregister_connection(ConnectionId::new(1))
12400            .unwrap();
12401        actor.module.restart(Some(0)).await.unwrap();
12402        let parked = actor
12403            .wait_for_select(|checkpoint| {
12404                checkpoint.generation > armed.generation
12405                    && checkpoint.registered_connection.is_none()
12406                    && checkpoint.next_probe_at.is_none()
12407            })
12408            .await;
12409        assert_eq!(parked.wake_after, None);
12410        actor.rx.close();
12411        // Drain the stop notice the restart queued for the old process's connection,
12412        // so it isn't mistaken for traffic to the replacement.
12413        while actor.rx.try_recv().is_ok() {}
12414        let turns = actor.turns();
12415        tokio::time::advance(Duration::from_secs(60)).await;
12416        let after = actor.turns();
12417        eprintln!(
12418            "restart park turns: before={turns} after={after} delta={}",
12419            after - turns
12420        );
12421        assert_eq!(after, turns, "replacement must park before HELLO");
12422        let hello_tick = Instant::now();
12423        actor.hello(2, true);
12424        let rearmed = actor
12425            .wait_for_select(|checkpoint| {
12426                checkpoint.registered_connection == Some(ConnectionId::new(2))
12427                    && checkpoint.next_probe_at.is_some()
12428            })
12429            .await;
12430        let delay = jittered_health_delay(&actor.spec.module_id, 0, actor.health.cadence);
12431        assert_eq!(rearmed.next_probe_at, Some(hello_tick + delay));
12432        tokio::time::advance(delay).await;
12433        actor.probe().await;
12434    }
12435
12436    #[tokio::test(start_paused = true)]
12437    async fn rescan_adds_and_removes_http_health_without_polling() {
12438        use tokio::{
12439            io::{AsyncReadExt, AsyncWriteExt},
12440            net::TcpListener,
12441        };
12442        let mut actor = Actor::start(ModuleProtocol::None, Duration::from_secs(30)).await;
12443        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
12444        actor.health.http = Some(format!("http://{}/health", listener.local_addr().unwrap()));
12445        // One millisecond separates successive probes without a polling delay.
12446        actor.health.cadence = Duration::from_millis(1);
12447        let (started_tx, mut started_rx) = mpsc::channel(8);
12448        let peer = tokio::spawn(async move {
12449            loop {
12450                let (mut socket, _) = listener.accept().await.unwrap();
12451                let mut request = [0; 1024];
12452                assert!(socket.read(&mut request).await.unwrap() > 0);
12453                started_tx.send(()).await.unwrap();
12454                socket
12455                    .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
12456                    .await
12457                    .unwrap();
12458            }
12459        });
12460        let tick = Instant::now();
12461        let turns = actor.turns();
12462        actor
12463            .module
12464            .update_configuration(actor.spec.clone(), actor.health.clone(), None)
12465            .await
12466            .unwrap();
12467        let armed = actor
12468            .wait_for_select(|checkpoint| {
12469                checkpoint.turn > turns && checkpoint.next_probe_at.is_some()
12470            })
12471            .await;
12472        assert_eq!(armed.next_probe_at, Some(tick + Duration::from_millis(1)));
12473        assert_eq!(Instant::now(), tick);
12474        tokio::time::advance(Duration::from_millis(1)).await;
12475        let mut started = false;
12476        wait_for("successful HTTP probe", || {
12477            started |= started_rx.try_recv().is_ok();
12478            (started && actor.module.status().unwrap().health.status == SupervisorHealthStatus::Ok)
12479                .then_some(())
12480        })
12481        .await;
12482        assert!(started, "added HTTP check must start at its first deadline");
12483        assert_eq!(
12484            actor.module.status().unwrap().health.status,
12485            SupervisorHealthStatus::Ok
12486        );
12487        assert_eq!(Instant::now(), tick + Duration::from_millis(1));
12488        actor.health.http = None;
12489        let turns = actor.turns();
12490        actor
12491            .module
12492            .update_configuration(actor.spec.clone(), actor.health.clone(), None)
12493            .await
12494            .unwrap();
12495        let parked = actor
12496            .wait_for_select(|checkpoint| {
12497                checkpoint.turn > turns && checkpoint.next_probe_at.is_none()
12498            })
12499            .await;
12500        assert_eq!(parked.wake_after, None);
12501        let turns = actor.turns();
12502        tokio::time::advance(Duration::from_secs(60)).await;
12503        assert_eq!(
12504            actor.turns(),
12505            turns,
12506            "removed HTTP check must park the actor"
12507        );
12508        assert!(
12509            started_rx.try_recv().is_err(),
12510            "removed HTTP check must stay disarmed"
12511        );
12512        assert_eq!(
12513            actor.module.status().unwrap().health,
12514            ModuleHealthStatus::default()
12515        );
12516        peer.abort();
12517    }
12518}
12519
12520#[cfg(test)]
12521mod health_evidence_tests {
12522    use super::{HealthProbeError, HealthProbeEvidence};
12523    use std::collections::HashSet;
12524
12525    /// The evidential asymmetry, asserted rather than described.
12526    ///
12527    /// Exactly ONE observation is proof a module cannot serve, and the one that
12528    /// fires under CPU starvation is not it. Before the split, all fifteen
12529    /// construction sites collapsed into a single String, so a timeout carried the
12530    /// same weight as a dead lane -- which is how a healthy module was restarted
12531    /// three times in one day.
12532    #[test]
12533    fn only_a_dead_lane_is_proof_of_death() {
12534        assert!(HealthProbeError::lane_dead("gone").is_proof_of_death());
12535        // Three non-proof classes, each for a different reason: silence is
12536        // consistent with health, a bad answer proves the module ALIVE, and a
12537        // daemon-side fault never reached the module at all.
12538        assert!(!HealthProbeError::no_answer("timed out").is_proof_of_death());
12539        assert!(!HealthProbeError::bad_answer("garbage").is_proof_of_death());
12540        assert!(!HealthProbeError::misconfigured("no table").is_proof_of_death());
12541    }
12542
12543    /// Labels must be distinct, or the operator-facing distinction is cosmetic.
12544    ///
12545    /// A shared label renders two different observations identically in the line an
12546    /// operator reads after an unexplained restart -- the exact confusion this
12547    /// change removes.
12548    #[test]
12549    fn every_evidence_class_has_a_distinct_label() {
12550        let labels = [
12551            HealthProbeError::lane_dead("").label(),
12552            HealthProbeError::no_answer("").label(),
12553            HealthProbeError::bad_answer("").label(),
12554            HealthProbeError::misconfigured("").label(),
12555        ];
12556        let unique: HashSet<_> = labels.iter().collect();
12557        assert_eq!(unique.len(), labels.len(), "labels collided: {labels:?}");
12558    }
12559
12560    /// The class is additional information, not a replacement.
12561    ///
12562    /// An operator needs both "this was silence" and the specific text saying how
12563    /// long we waited; a classification that swallowed the message would trade one
12564    /// missing distinction for another.
12565    #[test]
12566    fn classification_preserves_the_original_message() {
12567        let err = HealthProbeError::no_answer("module did not answer within 5s");
12568        assert_eq!(err.to_string(), "module did not answer within 5s");
12569        assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
12570    }
12571}
12572
12573#[cfg(test)]
12574mod health_tombstone_tests {
12575    use std::{path::PathBuf, sync::Arc, time::Duration};
12576
12577    use subc_protocol::{
12578        manifest::Concurrency,
12579        session::{HealthStatus, ModuleControlResponse},
12580    };
12581    use tokio::sync::mpsc;
12582
12583    use super::{
12584        probe_module_health, HealthAction, HealthConfig, HealthProbeEvidence, ModuleProtocol,
12585        ModuleSpec, RestartPolicy, Supervisor, SupervisorRuntimeConfig,
12586    };
12587    use crate::{
12588        control::ControlHandler,
12589        forwarding::{ForwardingTable, ModuleControlRpcCompletion, ModuleControlRpcOutcome},
12590        registry::{ConnectionId, Registry},
12591        router::FrameSink,
12592    };
12593
12594    struct ProbeHarness {
12595        spec: ModuleSpec,
12596        runtime: SupervisorRuntimeConfig,
12597        forwarding: Arc<ForwardingTable>,
12598        module_connection: ConnectionId,
12599        module_rx: mpsc::Receiver<crate::router::OutboundFrame>,
12600        handler: ControlHandler,
12601        module: super::SupervisedModule,
12602    }
12603
12604    fn probe_harness() -> ProbeHarness {
12605        let registry = Arc::new(Registry::default());
12606        let forwarding = Arc::new(ForwardingTable::default());
12607        let supervisor_handle = super::SupervisorHandle::new();
12608        let health = HealthConfig {
12609            http: None,
12610            cadence: Duration::from_secs(30),
12611            deadline: Duration::from_secs(5),
12612            failure_threshold: 3,
12613            on_degraded: HealthAction::Report,
12614            on_failing: HealthAction::Report,
12615            critical: false,
12616        };
12617        let supervisor = Supervisor::new_for_test(Arc::clone(&registry), RestartPolicy::default())
12618            .with_forwarding(Arc::clone(&forwarding))
12619            .with_handle(supervisor_handle.clone())
12620            .with_health_config(health);
12621        let spec = ModuleSpec {
12622            module_id: "late-health-module".to_string(),
12623            program: PathBuf::from("disabled-module"),
12624            args: Vec::new(),
12625            env: Vec::new(),
12626            reserved: false,
12627            reserved_prefixes: Vec::new(),
12628            protocol: ModuleProtocol::Subc,
12629            overlap: Default::default(),
12630        };
12631        let module = supervisor
12632            .supervise_configured(spec.clone(), false)
12633            .unwrap();
12634        let runtime = supervisor.runtime_config();
12635        let handler = ControlHandler::with_forwarding(registry, Arc::clone(&forwarding))
12636            .with_supervisor(supervisor_handle);
12637        let module_connection = ConnectionId::new(700);
12638        let (module_tx, module_rx) = mpsc::channel(8);
12639        forwarding
12640            .register_module_connection(
12641                module_connection,
12642                spec.module_id.clone(),
12643                subc_protocol::PROTOCOL_VERSION,
12644                Concurrency::ModuleManaged,
12645                FrameSink::new(module_tx),
12646            )
12647            .unwrap();
12648
12649        ProbeHarness {
12650            spec,
12651            runtime,
12652            forwarding,
12653            module_connection,
12654            module_rx,
12655            handler,
12656            module,
12657        }
12658    }
12659
12660    async fn finish_after(
12661        harness: &mut ProbeHarness,
12662        stall: Duration,
12663    ) -> ModuleControlRpcCompletion {
12664        assert!(stall > harness.runtime.health.deadline);
12665        let deadline = harness.runtime.health.deadline;
12666        let probe = probe_module_health(&harness.spec.module_id, &harness.runtime, None);
12667        let answer = async {
12668            let frame = harness.module_rx.recv().await.expect("health.check frame");
12669            tokio::time::advance(deadline).await;
12670            tokio::task::yield_now().await;
12671            tokio::time::advance(stall - deadline).await;
12672            harness
12673                .forwarding
12674                .complete_module_control_rpc(
12675                    harness.module_connection,
12676                    frame.header.corr,
12677                    Some("health.check"),
12678                    ModuleControlRpcOutcome::Response(ModuleControlResponse::HealthCheck {
12679                        status: HealthStatus::Ok,
12680                        detail: None,
12681                        metrics: None,
12682                    }),
12683                )
12684                .unwrap()
12685        };
12686        let (probe_result, completion) = tokio::join!(probe, answer);
12687        let err = probe_result.expect_err("probe must miss its deadline");
12688        assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
12689        completion
12690    }
12691
12692    async fn time_out_without_answer(harness: &mut ProbeHarness) {
12693        let deadline = harness.runtime.health.deadline;
12694        let probe = probe_module_health(&harness.spec.module_id, &harness.runtime, None);
12695        let exhaust_deadline = async {
12696            let _frame = harness.module_rx.recv().await.expect("health.check frame");
12697            tokio::time::advance(deadline).await;
12698            tokio::task::yield_now().await;
12699        };
12700        let (probe_result, ()) = tokio::join!(probe, exhaust_deadline);
12701        let err = probe_result.expect_err("probe must miss its deadline");
12702        assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
12703    }
12704
12705    async fn run_probe_cycle(harness: &mut ProbeHarness, answer: bool) {
12706        let registry = Arc::clone(&harness.module.inner.registry);
12707        let snapshot = Arc::clone(&harness.module.inner.snapshot);
12708        let process_liveness = super::SupervisorProcessLiveness::default();
12709        let mut child = None;
12710        let cycle = super::run_health_probe_cycle(
12711            &harness.spec,
12712            &harness.runtime,
12713            &registry,
12714            &process_liveness,
12715            &snapshot,
12716            &mut child,
12717        );
12718        let peer = async {
12719            let frame = harness.module_rx.recv().await.expect("health.check frame");
12720            if answer {
12721                harness
12722                    .forwarding
12723                    .complete_module_control_rpc(
12724                        harness.module_connection,
12725                        frame.header.corr,
12726                        Some("health.check"),
12727                        ModuleControlRpcOutcome::Response(ModuleControlResponse::HealthCheck {
12728                            status: HealthStatus::Ok,
12729                            detail: None,
12730                            metrics: Some(serde_json::json!({"ready": true})),
12731                        }),
12732                    )
12733                    .unwrap();
12734            } else {
12735                tokio::time::advance(harness.runtime.health.deadline).await;
12736                tokio::task::yield_now().await;
12737            }
12738        };
12739        tokio::join!(cycle, peer);
12740    }
12741
12742    #[tokio::test(start_paused = true)]
12743    async fn unanswered_probe_is_unknown_until_threshold_and_ok_report_recovers() {
12744        let mut harness = probe_harness();
12745        // Drive the probe cycle directly with an in-memory wire peer. Stop the
12746        // disabled module's monitor so only this test owns lifecycle transitions;
12747        // no OS process is launched, and a restart is observed at scheduling.
12748        let monitor = harness.module.inner.monitor.lock().unwrap().take().unwrap();
12749        monitor.abort();
12750        let _ = monitor.await;
12751        super::update_snapshot(&harness.module.inner.snapshot, None, |state| {
12752            state.enabled = true;
12753            state.state = super::ModuleState::Running;
12754            state.process_alive = true;
12755        })
12756        .unwrap();
12757
12758        run_probe_cycle(&mut harness, true).await;
12759        assert_eq!(
12760            harness.module.status().unwrap().health.status,
12761            super::SupervisorHealthStatus::Ok
12762        );
12763
12764        for failures in 1..harness.runtime.health.failure_threshold {
12765            run_probe_cycle(&mut harness, false).await;
12766            let status = harness.module.status().unwrap();
12767            assert_eq!(status.health.status, super::SupervisorHealthStatus::Unknown);
12768            assert_eq!(status.health.consecutive_failures, failures);
12769            assert!(status.health.last_probe_ms.is_some());
12770            assert!(status.health.detail.unwrap().starts_with("[no-answer]"));
12771            assert!(status.health.metrics.is_none());
12772            assert_eq!(status.state, super::ModuleState::Running);
12773            assert!(status.process_alive);
12774            assert_eq!(status.restart_count, 0);
12775            assert_eq!(status.lifetime_restarts, 0);
12776            assert!(status.health.last_action.is_none());
12777            assert!(harness.runtime.scheduled_respawn.lock().unwrap().is_none());
12778        }
12779
12780        run_probe_cycle(&mut harness, true).await;
12781        let recovered = harness.module.status().unwrap();
12782        assert_eq!(recovered.health.status, super::SupervisorHealthStatus::Ok);
12783        assert_eq!(recovered.health.consecutive_failures, 0);
12784        assert!(recovered.health.detail.is_none());
12785        assert_eq!(
12786            recovered.health.metrics,
12787            Some(serde_json::json!({"ready": true}))
12788        );
12789        assert_eq!(recovered.lifetime_restarts, 0);
12790
12791        for failures in 1..=harness.runtime.health.failure_threshold {
12792            run_probe_cycle(&mut harness, false).await;
12793            let status = harness.module.status().unwrap();
12794            assert_eq!(status.health.consecutive_failures, failures);
12795            if failures < harness.runtime.health.failure_threshold {
12796                assert_eq!(status.health.status, super::SupervisorHealthStatus::Unknown);
12797                assert_eq!(status.state, super::ModuleState::Running);
12798                assert_eq!(status.lifetime_restarts, 0);
12799                assert!(harness.runtime.scheduled_respawn.lock().unwrap().is_none());
12800            } else {
12801                assert_eq!(
12802                    status.health.status,
12803                    super::SupervisorHealthStatus::Unresponsive
12804                );
12805                assert_eq!(status.state, super::ModuleState::Restarting);
12806                assert_eq!(status.restart_count, 1);
12807                assert_eq!(status.lifetime_restarts, 1);
12808                assert_eq!(status.health.last_action.as_deref(), Some("restart"));
12809                assert!(harness.runtime.scheduled_respawn.lock().unwrap().is_some());
12810            }
12811        }
12812    }
12813
12814    #[tokio::test(start_paused = true)]
12815    async fn late_health_answers_record_start_anchored_latency_for_two_stalls() {
12816        let mut harness = probe_harness();
12817
12818        let first = finish_after(&mut harness, Duration::from_secs(8)).await;
12819        let first_latency = match &first {
12820            ModuleControlRpcCompletion::LateHealthAnswer { latency, .. } => *latency,
12821            other => panic!("late answer was not retained: {other:?}"),
12822        };
12823        assert!(harness.handler.observe_module_control_completion(first));
12824
12825        let second = finish_after(&mut harness, Duration::from_secs(11)).await;
12826        let second_latency = match &second {
12827            ModuleControlRpcCompletion::LateHealthAnswer { latency, .. } => *latency,
12828            other => panic!("late answer was not retained: {other:?}"),
12829        };
12830        assert!(harness.handler.observe_module_control_completion(second));
12831
12832        assert_eq!(first_latency, Duration::from_secs(8));
12833        assert_eq!(
12834            second_latency - first_latency,
12835            Duration::from_secs(3),
12836            "latency must grow linearly with the additional stall"
12837        );
12838        let health = harness.module.status().unwrap().health;
12839        assert_eq!(health.late_answer_count, 2);
12840        assert_eq!(health.last_late_answer_latency_ms, Some(11_000));
12841    }
12842
12843    /// A module that answers every probe late must never march to the kill
12844    /// threshold: the late answer proves it is alive, so it must clear the miss
12845    /// streak the timeout recorded. Without the reset, a CPU-starved module
12846    /// that serves every probe seconds past the deadline accumulates
12847    /// `consecutive_failures` to the threshold and is killed — the exact
12848    /// sequence from the 2026-08-14 aft disable, where the daemon logged
12849    /// "proves the module is alive" five times while counting five misses.
12850    #[tokio::test(start_paused = true)]
12851    async fn late_answer_clears_the_consecutive_failure_streak() {
12852        let mut harness = probe_harness();
12853
12854        // Timeout recorded first: the probe path saw no answer in time.
12855        time_out_without_answer(&mut harness).await;
12856        harness
12857            .module
12858            .record_health_probe_failure_for_test("[no-answer] test miss")
12859            .unwrap();
12860        assert_eq!(
12861            harness.module.status().unwrap().health.consecutive_failures,
12862            1,
12863            "precondition: the miss must be on the streak before the late answer"
12864        );
12865
12866        // The stalled reply then lands: proof of life.
12867        let late = finish_after(&mut harness, Duration::from_secs(9)).await;
12868        assert!(matches!(
12869            late,
12870            ModuleControlRpcCompletion::LateHealthAnswer { .. }
12871        ));
12872        assert!(harness.handler.observe_module_control_completion(late));
12873
12874        let health = harness.module.status().unwrap().health;
12875        assert_eq!(
12876            health.consecutive_failures, 0,
12877            "a late answer is an answer: the streak must reset"
12878        );
12879        assert_eq!(health.late_answer_count, 1);
12880    }
12881
12882    #[tokio::test(start_paused = true)]
12883    async fn repeated_serial_probe_cycles_keep_one_tombstone_per_endpoint() {
12884        let mut harness = probe_harness();
12885
12886        for _ in 0..20 {
12887            time_out_without_answer(&mut harness).await;
12888            assert_eq!(
12889                harness.forwarding.health_probe_tombstone_count().unwrap(),
12890                1
12891            );
12892        }
12893    }
12894}
12895
12896#[cfg(test)]
12897mod child_env_tests {
12898    #[cfg(windows)]
12899    use super::{
12900        clear_current_process_facts, observed_launch_nonce_source, ModuleState, SupervisorSnapshot,
12901    };
12902    #[cfg(windows)]
12903    use std::sync::{Arc, OnceLock};
12904    #[cfg(windows)]
12905    #[test]
12906    fn windows_wire_spawn_prepares_pipe_and_keeps_phase_one_env() {
12907        let spec = spec(Vec::new());
12908        let mut command = Command::new("unused");
12909        let handoff = apply_wire_spawn_args(&mut command, &spec, None, None)
12910            .unwrap()
12911            .unwrap();
12912        let name = command
12913            .as_std()
12914            .get_envs()
12915            .find(|(key, _)| *key == OsStr::new(subc_os::launch_nonce::LAUNCH_NONCE_PIPE_ENV))
12916            .and_then(|(_, value)| value)
12917            .unwrap();
12918        assert_eq!(name, OsStr::new(handoff.name()));
12919        assert!(handoff.name().starts_with(r"\\.\pipe\subc-launch-"));
12920        assert!(command.as_std().get_envs().any(|(key, value)| key
12921            == OsStr::new(subc_os::LAUNCH_NONCE_PIPE_FALLBACK_ENV)
12922            && value == Some(OsStr::new("env"))));
12923        assert!(command
12924            .as_std()
12925            .get_envs()
12926            .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some()));
12927    }
12928
12929    #[cfg(windows)]
12930    #[test]
12931    fn windows_source_reporting_tracks_consumption_and_resets_per_spawn() {
12932        let mut snapshot = SupervisorSnapshot::new(ModuleState::Running, true);
12933        snapshot.process_alive = true;
12934        snapshot.spawned_protocol = Some(ModuleProtocol::Subc);
12935        assert_eq!(
12936            observed_launch_nonce_source(&snapshot).as_deref(),
12937            Some("env")
12938        );
12939        let consumed = Arc::new(OnceLock::new());
12940        snapshot.pipe_consumed = Some(consumed.clone());
12941        assert_eq!(
12942            observed_launch_nonce_source(&snapshot).as_deref(),
12943            Some("env")
12944        );
12945        consumed.set(()).unwrap();
12946        assert_eq!(
12947            observed_launch_nonce_source(&snapshot).as_deref(),
12948            Some("pipe")
12949        );
12950        snapshot.pipe_consumed = Some(Arc::new(OnceLock::new()));
12951        assert_eq!(
12952            observed_launch_nonce_source(&snapshot).as_deref(),
12953            Some("env")
12954        );
12955        clear_current_process_facts(&mut snapshot);
12956        assert_eq!(observed_launch_nonce_source(&snapshot), None);
12957    }
12958    use super::{
12959        apply_child_env, apply_spawn_role, apply_wire_spawn_args, ModuleProtocol, ModuleSpec,
12960        SpawnRole, SupervisorHandle, SPAWN_ROLE_SWAP_CANDIDATE, SUBC_ARG, SUBC_LAUNCH_NONCE_ENV,
12961        SUBC_MODULE_ID_ENV, SUBC_SPAWN_ROLE_ENV,
12962    };
12963    use std::{ffi::OsStr, path::PathBuf};
12964    use tokio::process::Command;
12965
12966    fn spec(env: Vec<(String, String)>) -> ModuleSpec {
12967        ModuleSpec {
12968            module_id: "env-plan".to_string(),
12969            program: PathBuf::from("/nonexistent"),
12970            args: Vec::new(),
12971            env,
12972            reserved: false,
12973            reserved_prefixes: Vec::new(),
12974            protocol: ModuleProtocol::Subc,
12975            overlap: Default::default(),
12976        }
12977    }
12978
12979    /// Ambient `CK_LOG` is REMOVED for an unconfigured module, and a configured
12980    /// one still gets its own.
12981    ///
12982    /// This is the narrow goal `env_clear()` was reached for, and the reason the
12983    /// fix is `env_remove` rather than deleting the line: an operator's ambient
12984    /// filter silently becoming an unconfigured module's log level is a real
12985    /// defect, just a much smaller one than clearing the environment.
12986    ///
12987    /// Asserted on the command plan rather than a spawned child because proving
12988    /// the ABSENCE of an inherited variable needs the parent's environment
12989    /// mutated, and `forbid(unsafe_code)` refuses that. `get_envs()` reports a
12990    /// removal as `(key, None)`, which is exactly the distinction wanted: not
12991    /// "absent because nobody set it" but "explicitly unset for the child".
12992    #[test]
12993    fn ambient_ck_log_is_removed_and_a_configured_one_survives() {
12994        let mut command = Command::new("/nonexistent");
12995        apply_child_env(&mut command, &spec(Vec::new()));
12996        let removed = command
12997            .as_std()
12998            .get_envs()
12999            .any(|(key, value)| key == OsStr::new("CK_LOG") && value.is_none());
13000        assert!(
13001            removed,
13002            "ambient CK_LOG must be explicitly removed for an unconfigured module"
13003        );
13004
13005        let mut configured = Command::new("/nonexistent");
13006        apply_child_env(
13007            &mut configured,
13008            &spec(vec![("CK_LOG".to_string(), "debug".to_string())]),
13009        );
13010        let effective = configured
13011            .as_std()
13012            .get_envs()
13013            .filter(|(key, _)| *key == OsStr::new("CK_LOG"))
13014            .last()
13015            .map(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()));
13016        assert_eq!(
13017            effective,
13018            Some(Some("debug".to_string())),
13019            "a module's configured CK_LOG must survive the ambient removal"
13020        );
13021    }
13022
13023    /// A `protocol: "none"` spawn carries NO `--subc` argument and NO launch
13024    /// nonce; a subc-wire spawn carries both. Asserted on the command plan for
13025    /// the same reason as the CK_LOG test above.
13026    ///
13027    /// The argument is the load-bearing half: a stock binary exits on an
13028    /// unknown flag before it listens, so with `--subc` appended the mode
13029    /// could not supervise the one process it exists for. Found by the first
13030    /// conformance run (nats-server: `flag provided but not defined: -subc`).
13031    #[test]
13032    fn protocol_none_spawn_carries_no_subc_argument_and_no_nonce() {
13033        let connection_file = std::path::Path::new("/run/subc-connection.json");
13034        let handle = SupervisorHandle::new();
13035
13036        let mut none_spec = spec(Vec::new());
13037        none_spec.protocol = ModuleProtocol::None;
13038        let mut none = Command::new("/nonexistent");
13039        let none_handoff =
13040            apply_wire_spawn_args(&mut none, &none_spec, Some(connection_file), Some(&handle))
13041                .expect("protocol-none spawn args apply");
13042        assert!(
13043            none_handoff.is_none(),
13044            "protocol:none spawn must not receive a nonce descriptor"
13045        );
13046        #[cfg(windows)]
13047        assert!(!none.as_std().get_envs().any(|(key, value)| key
13048            == OsStr::new(subc_os::LAUNCH_NONCE_PIPE_FALLBACK_ENV)
13049            && value.is_some()));
13050        assert!(
13051            !none.as_std().get_envs().any(|(key, value)| key
13052                == OsStr::new(subc_os::LAUNCH_NONCE_FD_ENV)
13053                && value.is_some()),
13054            "protocol:none spawn must not name a nonce descriptor"
13055        );
13056        let none_args: Vec<String> = none
13057            .as_std()
13058            .get_args()
13059            .map(|a| a.to_string_lossy().into_owned())
13060            .collect();
13061        assert!(
13062            !none_args.iter().any(|a| a == SUBC_ARG),
13063            "protocol:none argv must not carry --subc; got {none_args:?}"
13064        );
13065        let none_has_nonce = none
13066            .as_std()
13067            .get_envs()
13068            .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some());
13069        assert!(
13070            !none_has_nonce,
13071            "protocol:none spawn must not receive a launch nonce"
13072        );
13073        let none_has_module_id = none
13074            .as_std()
13075            .get_envs()
13076            .any(|(key, value)| key == OsStr::new(SUBC_MODULE_ID_ENV) && value.is_some());
13077        assert!(
13078            none_has_module_id,
13079            "SUBC_MODULE_ID is inert and stays on every path"
13080        );
13081        assert!(
13082            handle.spawn_nonce(&none_spec.module_id).is_none(),
13083            "no nonce record for a process that will never present one"
13084        );
13085
13086        // Control: the subc-wire path is unchanged by the branch above.
13087        let wire_spec = spec(Vec::new());
13088        let mut wire = Command::new("/nonexistent");
13089        let wire_handoff =
13090            apply_wire_spawn_args(&mut wire, &wire_spec, Some(connection_file), Some(&handle))
13091                .expect("subc-wire spawn args apply");
13092        let wire_fd_env = wire
13093            .as_std()
13094            .get_envs()
13095            .find(|(key, _)| *key == OsStr::new(subc_os::LAUNCH_NONCE_FD_ENV))
13096            .and_then(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()));
13097        #[cfg(unix)]
13098        assert_eq!(
13099            wire_fd_env,
13100            Some(wire_handoff.expect("a descriptor handoff").fd_env_value()),
13101            "a subc-wire spawn names the pipe it will receive at descriptor 3"
13102        );
13103        #[cfg(windows)]
13104        assert!(wire_handoff.is_some() && wire_fd_env.is_none());
13105        #[cfg(not(any(unix, windows)))]
13106        assert!(wire_handoff.is_none() && wire_fd_env.is_none());
13107        let wire_args: Vec<String> = wire
13108            .as_std()
13109            .get_args()
13110            .map(|a| a.to_string_lossy().into_owned())
13111            .collect();
13112        assert_eq!(
13113            wire_args,
13114            vec![
13115                SUBC_ARG.to_string(),
13116                connection_file.to_string_lossy().into_owned()
13117            ],
13118            "a subc-wire spawn still carries --subc <path>"
13119        );
13120        assert_eq!(
13121            wire.as_std()
13122                .get_envs()
13123                .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some()),
13124            !cfg!(unix),
13125            "only Windows supplies the environment nonce"
13126        );
13127        assert!(handle.spawn_nonce(&wire_spec.module_id).is_some());
13128    }
13129
13130    /// A plain spawn EXPLICITLY REMOVES the spawn role, even when the module's
13131    /// spec tries to set it; only a swap candidate carries it.
13132    ///
13133    /// "Set it only on candidates" is not enough, because spawn applies the
13134    /// spec's env verbatim and the daemon's own environment is inherited: either
13135    /// could hand a plain restart the swap role, and a module reading it would
13136    /// warm on its long swap budget while callers wait. Asserted as an explicit
13137    /// removal (`(key, None)`), not mere absence, for the reason the `CK_LOG`
13138    /// test above gives.
13139    #[test]
13140    fn plain_spawn_removes_the_spawn_role_even_when_the_spec_sets_it() {
13141        let role = |command: &Command| {
13142            command
13143                .as_std()
13144                .get_envs()
13145                .filter(|(key, _)| *key == OsStr::new(SUBC_SPAWN_ROLE_ENV))
13146                .last()
13147                .map(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()))
13148        };
13149        let forged = spec(vec![(
13150            SUBC_SPAWN_ROLE_ENV.to_string(),
13151            SPAWN_ROLE_SWAP_CANDIDATE.to_string(),
13152        )]);
13153
13154        let mut plain = Command::new("/nonexistent");
13155        apply_child_env(&mut plain, &forged);
13156        apply_spawn_role(&mut plain, SpawnRole::Plain);
13157        assert_eq!(
13158            role(&plain),
13159            Some(None),
13160            "a plain spawn must remove SUBC_SPAWN_ROLE, whatever the spec says"
13161        );
13162
13163        let mut candidate = Command::new("/nonexistent");
13164        apply_child_env(&mut candidate, &spec(Vec::new()));
13165        apply_spawn_role(&mut candidate, SpawnRole::SwapCandidate);
13166        assert_eq!(
13167            role(&candidate),
13168            Some(Some(SPAWN_ROLE_SWAP_CANDIDATE.to_string()))
13169        );
13170    }
13171
13172    /// Daemon-private capture retention keys never reach the child.
13173    ///
13174    /// cortexkit-log exposes retention as a Rust struct with no environment
13175    /// names, so these entries are supervisor metadata. Passing them through
13176    /// would invent a public child-process contract by accident.
13177    #[test]
13178    fn daemon_private_capture_keys_are_not_passed_to_the_child() {
13179        let mut command = Command::new("/nonexistent");
13180        apply_child_env(
13181            &mut command,
13182            &spec(vec![
13183                (super::CAPTURE_KEEP_ENV.to_string(), "5".to_string()),
13184                ("KEPT".to_string(), "yes".to_string()),
13185            ]),
13186        );
13187        let keys: Vec<String> = command
13188            .as_std()
13189            .get_envs()
13190            .filter(|(_, value)| value.is_some())
13191            .map(|(key, _)| key.to_string_lossy().into_owned())
13192            .collect();
13193        assert!(keys.contains(&"KEPT".to_string()), "got {keys:?}");
13194        assert!(
13195            !keys.contains(&super::CAPTURE_KEEP_ENV.to_string()),
13196            "daemon-private capture key leaked to the child: {keys:?}"
13197        );
13198    }
13199}
13200
13201#[cfg(test)]
13202mod jitter_tests {
13203    use super::jittered_health_delay;
13204    use std::{collections::HashSet, time::Duration};
13205
13206    /// Module ids drawn from a real fleet, so the dispersal claim is about names
13207    /// that actually occur rather than invented ones.
13208    ///
13209    /// This is a SAMPLE, not a registry: the property under test is that distinct
13210    /// ids disperse, which holds for any set of distinct strings. Several entries
13211    /// are already historical (modules get renamed), and that costs nothing here --
13212    /// but it means a reader must not mistake this for the live module set, and a
13213    /// rename sweep will match it without there being anything to change.
13214    const FLEET: [&str; 14] = [
13215        "aft",
13216        "alfonso-core",
13217        "magic-context",
13218        "broca",
13219        "thalamus",
13220        "quota",
13221        "engram",
13222        "plexus",
13223        "cerebellum",
13224        "astrocyte",
13225        "synapse",
13226        "subc-mcp",
13227        "cortexkit-credentials",
13228        "subc-federation",
13229    ];
13230
13231    /// Probes must not converge after a fleet-wide restart.
13232    ///
13233    /// This is the property the jitter exists for: every module reconnects at
13234    /// once, and without dispersal all fourteen would then probe on the same
13235    /// tick forever. Nothing failed visibly when this went untested -- a
13236    /// convergent fleet still probes correctly, just in a burst, so the symptom
13237    /// is a periodic load spike that looks like whatever else is running.
13238    #[test]
13239    fn probe_delays_disperse_across_the_fleet() {
13240        let cadence = Duration::from_secs(30);
13241        let delays: HashSet<Duration> = FLEET
13242            .iter()
13243            .map(|id| jittered_health_delay(id, 0, cadence))
13244            .collect();
13245        assert_eq!(
13246            delays.len(),
13247            FLEET.len(),
13248            "every supervised module must land on its own probe offset"
13249        );
13250    }
13251
13252    /// The offset may only ever DELAY a probe, never bring it forward.
13253    ///
13254    /// A delay below the cadence would probe a module more often than
13255    /// configured, which is the opposite of what an operator asked for and
13256    /// would tighten the failure budget without anyone changing it.
13257    #[test]
13258    fn jitter_only_delays_and_stays_within_one_tenth_of_cadence() {
13259        let cadence = Duration::from_secs(30);
13260        let span = cadence / 10;
13261        for id in FLEET {
13262            for probe_index in 0..8 {
13263                let delay = jittered_health_delay(id, probe_index, cadence);
13264                assert!(
13265                    delay >= cadence,
13266                    "{id}#{probe_index}: jitter must not shorten the cadence"
13267                );
13268                assert!(
13269                    delay < cadence + span,
13270                    "{id}#{probe_index}: jitter must stay inside one tenth of the cadence"
13271                );
13272            }
13273        }
13274    }
13275
13276    /// A module keeps its offset across daemon restarts.
13277    ///
13278    /// The delay is derived rather than randomised precisely so a restart does
13279    /// not re-roll every module into a fresh chance of collision. A random
13280    /// source would satisfy the dispersal test above and quietly lose this.
13281    #[test]
13282    fn a_module_offset_is_stable_across_restarts() {
13283        let cadence = Duration::from_secs(30);
13284        for id in FLEET {
13285            assert_eq!(
13286                jittered_health_delay(id, 0, cadence),
13287                jittered_health_delay(id, 0, cadence),
13288                "{id}: the same module and probe index must produce the same offset"
13289            );
13290        }
13291    }
13292
13293    /// A zero cadence disables probing rather than producing a busy loop.
13294    #[test]
13295    fn zero_cadence_yields_zero_delay() {
13296        assert_eq!(
13297            jittered_health_delay("aft", 0, Duration::ZERO),
13298            Duration::ZERO
13299        );
13300    }
13301}
13302
13303#[cfg(all(test, target_os = "linux"))]
13304mod cgroup_placement_tests {
13305    use super::{
13306        apply_cgroup_placement, remove_module_cgroup, ModuleProtocol, ModuleSpec, SuperviseError,
13307        SupervisedChild,
13308    };
13309    use crate::stderr_tail::{StderrRing, StderrTailConfig};
13310    use cortexkit_test_support::ScratchDir;
13311    use std::{
13312        fs, io,
13313        path::{Path, PathBuf},
13314        sync::{Arc, Mutex},
13315    };
13316    use tokio::process::Command;
13317
13318    #[tokio::test]
13319    async fn unique_spawn_cgroups_clean_up_across_restarts_and_shutdown() {
13320        use super::*;
13321        let dir = ScratchDir::new("unique-spawn-cgroups");
13322        let root = PathBuf::from(format!(
13323            "/sys/fs/cgroup/subc-unique-test-{}-{}",
13324            std::process::id(),
13325            unix_ms_now()
13326        ));
13327        if let Err(error) = fs::create_dir(&root) {
13328            assert!(
13329                std::env::var_os("SUBC_REQUIRE_CGROUP_TEST").is_none(),
13330                "required cgroup test cannot execute: {error}"
13331            );
13332            eprintln!("SKIP unique_spawn_cgroups_clean_up_across_restarts_and_shutdown: {error}");
13333            return;
13334        }
13335        let placement = subc_cgroup::prepare_at(&root).unwrap().unwrap();
13336        let group_count = || {
13337            fs::read_dir(root.join("subc-modules"))
13338                .unwrap()
13339                .map(|entry| entry.unwrap().file_type().unwrap())
13340                .filter(|kind| kind.is_dir())
13341                .count()
13342        };
13343        let supervisor =
13344            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
13345                .with_cgroup_placement(Some(placement.clone()));
13346        let runtime = supervisor.runtime_config();
13347        let mut spec = ModuleSpec {
13348            module_id: "unique-spawn".into(),
13349            program: PathBuf::from("/bin/sleep"),
13350            args: vec!["60".into()],
13351            env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
13352                .into_iter()
13353                .map(|key| (key.into(), dir.to_string_lossy().into_owned()))
13354                .collect(),
13355            reserved: false,
13356            reserved_prefixes: vec![],
13357            protocol: ModuleProtocol::None,
13358            overlap: Default::default(),
13359        };
13360        let spawn = |spec: &ModuleSpec| {
13361            spawn_child(
13362                spec,
13363                None,
13364                None,
13365                &runtime.stderr_ring,
13366                None,
13367                &runtime.child_roster,
13368                Some(&placement),
13369            )
13370            .unwrap()
13371        };
13372        let mut live = spawn(&spec);
13373        for _ in 0..3 {
13374            // A new process can enter the old slot while retirement is pending.
13375            let next = spawn(&spec);
13376            assert_ne!(live.module_id, next.module_id);
13377            live.start_kill().unwrap();
13378            live.wait().await.unwrap();
13379            live = next;
13380            assert!(
13381                live.child.try_wait().unwrap().is_none(),
13382                "retiring the old slot must not kill the replacement"
13383            );
13384            assert_eq!(
13385                group_count(),
13386                1,
13387                "only the live spawn's cgroup should remain"
13388            );
13389        }
13390        supervisor.begin_daemon_shutdown();
13391        let reap = tokio::spawn(async move {
13392            live.wait().await.unwrap();
13393        });
13394        supervisor
13395            .end_children_for_daemon_shutdown(false, std::future::pending())
13396            .await;
13397        reap.await.unwrap();
13398        assert_eq!(group_count(), 0);
13399        // A normal exit uses the same tree-cleanup path as a killed spawn.
13400        spec.program = PathBuf::from("/bin/true");
13401        spec.args.clear();
13402        let fresh_roster = ChildRoster::default();
13403        let mut short = spawn_child(
13404            &spec,
13405            None,
13406            None,
13407            &runtime.stderr_ring,
13408            None,
13409            &fresh_roster,
13410            Some(&placement),
13411        )
13412        .unwrap();
13413        short.wait().await.unwrap();
13414        assert_eq!(group_count(), 0);
13415        spec.module_id = "_".repeat(255);
13416        let mut long_id = spawn_child(
13417            &spec,
13418            None,
13419            None,
13420            &runtime.stderr_ring,
13421            None,
13422            &fresh_roster,
13423            Some(&placement),
13424        )
13425        .unwrap();
13426        long_id.wait().await.unwrap();
13427        assert_eq!(
13428            group_count(),
13429            0,
13430            "valid long module IDs must not exceed cgroup NAME_MAX"
13431        );
13432        fs::remove_dir(root.join("subc-modules")).unwrap();
13433        fs::remove_dir(root).unwrap();
13434        eprintln!("EXECUTED unique_spawn_cgroups_clean_up_across_restarts_and_shutdown");
13435    }
13436
13437    #[test]
13438    fn failed_parent_cgroup_open_is_a_cgroup_supervision_error() {
13439        let path = Path::new("/definitely-missing-subc-cgroup");
13440        let mut command = Command::new("true");
13441        let error = apply_cgroup_placement(
13442            &mut command,
13443            &ModuleSpec {
13444                module_id: "broken-cgroup".to_string(),
13445                program: PathBuf::from("true"),
13446                args: Vec::new(),
13447                env: Vec::new(),
13448                reserved: false,
13449                reserved_prefixes: Vec::new(),
13450                protocol: ModuleProtocol::Subc,
13451                overlap: Default::default(),
13452            },
13453            path,
13454        )
13455        .expect_err("a parent cgroup open failure must reject the supervised spawn");
13456        let reason = error.to_string();
13457
13458        assert!(
13459            matches!(error, SuperviseError::Cgroup { .. }),
13460            "parent cgroup open must be reported as a cgroup supervision error: {reason}"
13461        );
13462        assert!(
13463            reason.contains("/definitely-missing-subc-cgroup/cgroup.procs"),
13464            "parent cgroup open failure must name cgroup.procs: {reason}"
13465        );
13466    }
13467
13468    #[tokio::test]
13469    async fn reaping_a_child_removes_its_empty_module_cgroup() {
13470        let root = ScratchDir::new("supervisor-reap-cgroup");
13471        fs::write(root.join("cgroup.procs"), b"").expect("write scratch cgroup marker");
13472        let placement = subc_cgroup::prepare_at(&root)
13473            .expect("prepare scratch cgroup root")
13474            .expect("scratch root has a cgroup.procs marker");
13475        let module_id = "reaped-module";
13476        let module = placement
13477            .module_path(module_id)
13478            .expect("create scratch module cgroup");
13479        let child = Command::new("true")
13480            .env("XDG_DATA_HOME", root.path())
13481            .env("XDG_RUNTIME_DIR", root.path())
13482            .env("XDG_CONFIG_HOME", root.path())
13483            .spawn()
13484            .expect("spawn short-lived child");
13485        let pid = child.id().expect("spawned child has pid");
13486        let mut child = SupervisedChild {
13487            child,
13488            protocol: ModuleProtocol::Subc,
13489            module_id: module_id.to_string(),
13490            cgroup_placement: Some(placement),
13491            stdout_pump: None,
13492            stderr_pump: None,
13493            stderr_ring: Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default()))),
13494            spawned_at_ms: 0,
13495            spawned_from: PathBuf::from("true"),
13496            spawned_file_identity: None,
13497            process_start_time: None,
13498            process_identity: None,
13499            pid,
13500            roster_guard: None,
13501            #[cfg(target_os = "macos")]
13502            privacy_exec: None,
13503            spawn_failure: None,
13504        };
13505
13506        child.wait().await.expect("reap short-lived child");
13507
13508        assert!(
13509            !module.exists(),
13510            "reaping the supervised child must remove its empty cgroup"
13511        );
13512    }
13513
13514    #[test]
13515    fn non_empty_cgroup_removal_is_reported_without_blocking_teardown() {
13516        let root = ScratchDir::new("supervisor-non-empty-cgroup");
13517        fs::write(root.join("cgroup.procs"), b"").expect("write scratch cgroup marker");
13518        let placement = subc_cgroup::prepare_at(&root)
13519            .expect("prepare scratch cgroup root")
13520            .expect("scratch root has a cgroup.procs marker");
13521        let module = placement
13522            .module_path("surviving-module")
13523            .expect("create scratch module cgroup");
13524        fs::write(module.join("surviving-process"), b"still present")
13525            .expect("make scratch cgroup non-empty");
13526        let (logs, _guard) = crate::router::test_log::log_capture(tracing::Level::WARN);
13527
13528        remove_module_cgroup(&placement, "surviving-module");
13529
13530        let logs = crate::router::test_log::captured_logs(&logs);
13531        assert!(
13532            module.exists(),
13533            "failed removal must leave the cgroup intact"
13534        );
13535        assert!(
13536            logs.contains("could not remove module cgroup after process exit; continuing teardown")
13537                && logs.contains("surviving-module"),
13538            "best-effort removal must report the failure without returning it: {logs}"
13539        );
13540    }
13541
13542    #[test]
13543    fn cgroup_pre_exec_spawn_failure_names_the_cgroup_path() {
13544        let cgroup_path = PathBuf::from("/sys/fs/cgroup/subc-modules/broken-module");
13545        let reason = SuperviseError::Spawn {
13546            program: PathBuf::from("/bin/true"),
13547            source: io::Error::from_raw_os_error(13),
13548            cgroup_path: Some(cgroup_path.clone()),
13549        }
13550        .to_string();
13551
13552        assert!(
13553            reason.contains(&cgroup_path.display().to_string()),
13554            "a pre_exec spawn failure must name the cgroup path: {reason}"
13555        );
13556    }
13557}
13558
13559#[cfg(test)]
13560mod spawn_subscriber_lag_tests {
13561    use super::*;
13562
13563    /// A subscriber whose connection stops draining is dropped once its frame
13564    /// channel fills. The client must learn that from a terminal Error frame
13565    /// after the frames already queued for it, not from a stream that simply
13566    /// goes quiet.
13567    #[tokio::test]
13568    async fn lagged_spawn_subscriber_receives_a_terminal_lagged_error_after_its_queued_frames() {
13569        let feed = SpawnEventFeed::default();
13570        feed.configure_incarnation("lag-incarnation".to_string());
13571        // A one-slot connection queue that nobody reads until the emits are
13572        // done: the forwarder parks on it and the subscriber channel fills.
13573        let (tx, mut rx) = mpsc::channel(1);
13574        feed.subscribe(ConnectionId::new(1), 7, 1, None, FrameSink::new(tx))
13575            .expect("subscribe");
13576        let emitted = SPAWN_SUBSCRIBER_BUFFER + 16;
13577        for index in 0..emitted {
13578            feed.emit_spawned(&format!("lag-module-{index}"), 1000, 0);
13579            // Let the forwarder take what it can so the fill point is the
13580            // subscriber channel, not a scheduling accident.
13581            tokio::task::yield_now().await;
13582        }
13583        assert_eq!(
13584            feed.subscriber_count(),
13585            0,
13586            "the lagged subscriber must be removed"
13587        );
13588
13589        let mut data = Vec::new();
13590        let mut last = None;
13591        loop {
13592            let next = tokio::time::timeout(Duration::from_secs(5), rx.recv())
13593                .await
13594                .expect("the forwarder must finish once the subscriber is dropped");
13595            let Some(outbound) = next else { break };
13596            let frame = outbound.frame;
13597            if frame.header.ty == FrameType::StreamData {
13598                assert!(last.is_none(), "no data may follow the terminal frame");
13599                let event: SpawnEvent = serde_json::from_slice(&frame.body).unwrap();
13600                data.push(event.cursor.seq);
13601            } else {
13602                assert!(last.is_none(), "exactly one terminal frame");
13603                last = Some(frame);
13604            }
13605        }
13606        assert!(!data.is_empty(), "queued frames drain before the terminal");
13607        for pair in data.windows(2) {
13608            assert_eq!(
13609                pair[1],
13610                pair[0] + 1,
13611                "queued frames arrive dense and in order"
13612            );
13613        }
13614        let terminal = last.expect("a lagged subscriber must receive a terminal frame");
13615        assert_eq!(terminal.header.ty, FrameType::Error);
13616        assert_eq!(terminal.header.corr, 7);
13617        let body: subc_protocol::ErrorBody = serde_json::from_slice(&terminal.body).unwrap();
13618        assert_eq!(body.code, SPAWN_SUBSCRIBER_LAGGED_CODE);
13619        let detail = body.detail.expect("lagged error carries detail");
13620        assert_eq!(
13621            detail["first_undelivered_cursor"]["seq"],
13622            data.last().unwrap() + 1,
13623            "the named cursor is the first event the subscriber did not receive"
13624        );
13625        assert_eq!(
13626            detail["first_undelivered_cursor"]["daemon_incarnation"],
13627            "lag-incarnation"
13628        );
13629    }
13630}
13631
13632#[cfg(test)]
13633mod terminal_history_read_concurrency_tests {
13634    use super::*;
13635    use crate::terminal_journal::read_pause;
13636    use cortexkit_test_support::ScratchDir;
13637    use std::sync::mpsc as std_mpsc;
13638
13639    fn journaled_ring(
13640        journal: &Arc<crate::terminal_journal::TerminalJournal>,
13641    ) -> Arc<Mutex<TerminalRing>> {
13642        Arc::new(Mutex::new(
13643            TerminalRing::new(TerminalRingConfig::default(), 1)
13644                .with_journal(Some(Arc::clone(journal))),
13645        ))
13646    }
13647
13648    fn crash(at_ms: u64) -> ExitReport {
13649        ExitReport {
13650            kind: ExitKind::Crash,
13651            code: Some(1),
13652            signal: None,
13653            at_ms,
13654        }
13655    }
13656
13657    /// Record an exit on another thread and report whether it finished within
13658    /// `bound`. The recorder thread is left running if it did not.
13659    fn record_within(
13660        module_id: &'static str,
13661        ring: &Arc<Mutex<TerminalRing>>,
13662        at_ms: u64,
13663        bound: Duration,
13664    ) -> bool {
13665        let ring = Arc::clone(ring);
13666        let (done, done_rx) = std_mpsc::channel();
13667        std::thread::spawn(move || {
13668            record_terminal(
13669                module_id,
13670                &ring,
13671                &SpawnEventFeed::default(),
13672                &crash(at_ms),
13673                TerminalDisposition::Restarting,
13674            );
13675            let _ = done.send(());
13676        });
13677        done_rx.recv_timeout(bound).is_ok()
13678    }
13679
13680    /// A history read in progress must not hold the journal writer (which every
13681    /// module's exit recording needs) or the module's own ring. Exits recorded
13682    /// while the read is paused complete promptly; the paused read answers as of
13683    /// the moment it started, and the next read has each exit exactly once.
13684    #[test]
13685    fn exits_recorded_during_a_paused_history_read_are_not_blocked_or_half_merged() {
13686        let dir = ScratchDir::new("terminal-history-concurrent-read");
13687        let path = dir.join("terminals.jsonl");
13688        let journal = Arc::new(crate::terminal_journal::TerminalJournal::open(
13689            path.clone(),
13690            "daemon".into(),
13691        ));
13692        let reader_ring = journaled_ring(&journal);
13693        let other_ring = journaled_ring(&journal);
13694        assert!(record_within(
13695            "reader-module",
13696            &reader_ring,
13697            10,
13698            Duration::from_secs(5)
13699        ));
13700
13701        let (started, release) = read_pause::install(&path);
13702        let reading = {
13703            let ring = Arc::clone(&reader_ring);
13704            std::thread::spawn(move || durable_terminal_history_of(&ring, "reader-module"))
13705        };
13706        started
13707            .recv_timeout(Duration::from_secs(5))
13708            .expect("the history read reached its pause");
13709
13710        let bound = Duration::from_secs(1);
13711        assert!(
13712            record_within("other-module", &other_ring, 20, bound),
13713            "another module's exit waited on a history read (journal writer held)"
13714        );
13715        assert!(
13716            record_within("reader-module", &reader_ring, 30, bound),
13717            "the read module's own exit waited on its history read (ring held)"
13718        );
13719
13720        drop(release);
13721        let paused = reading.join().unwrap();
13722        assert_eq!(
13723            paused.entries.iter().map(|e| e.at_ms).collect::<Vec<_>>(),
13724            vec![10],
13725            "an exit recorded after the read began lands in neither half of it"
13726        );
13727        assert_eq!(paused.journal_skipped_lines, 0);
13728        assert_eq!(paused.journal_read_errors, 0);
13729
13730        let after = durable_terminal_history_of(&reader_ring, "reader-module");
13731        assert_eq!(
13732            after.entries.iter().map(|e| e.at_ms).collect::<Vec<_>>(),
13733            vec![10, 30],
13734            "the next read merges ring and journal with no duplicate"
13735        );
13736        assert_eq!(after.journal_skipped_lines, 0);
13737    }
13738}
13739
13740/// What a restart does with the exited process's output readers. These drive
13741/// the same settle functions the supervisor calls, with a reader the test
13742/// holds, so a reader that has not been scheduled by the bound is a controlled
13743/// input rather than something only a loaded machine produces.
13744#[cfg(test)]
13745mod stderr_settle_tests {
13746    use std::{
13747        future::Future,
13748        io,
13749        pin::Pin,
13750        sync::{Arc, Mutex},
13751        task::{Context, Poll},
13752        time::Duration,
13753    };
13754
13755    use tokio::{
13756        io::{AsyncRead, ReadBuf},
13757        sync::oneshot,
13758        time::Instant,
13759    };
13760
13761    use super::{settle_stderr_pump, settle_stdout_pump, StderrPump};
13762    use crate::stderr_tail::{
13763        pump_stderr_to, pump_stdout_to, split_capture_stamp, untimed, CaptureState,
13764        ChildOutputSink, OutputSink, StderrRing, StderrTailConfig, TailEntry,
13765    };
13766
13767    const BOUND: Duration = Duration::from_millis(250);
13768
13769    /// Yields `before`, then stays pending until the gate is released, then
13770    /// yields `after` and reaches EOF. The bytes after the gate were written
13771    /// by a process that has already exited; only the reader is behind.
13772    struct HeldReader {
13773        before: Option<Vec<u8>>,
13774        gate: Option<oneshot::Receiver<()>>,
13775        after: io::Cursor<Vec<u8>>,
13776    }
13777
13778    impl AsyncRead for HeldReader {
13779        fn poll_read(
13780            mut self: Pin<&mut Self>,
13781            cx: &mut Context<'_>,
13782            buf: &mut ReadBuf<'_>,
13783        ) -> Poll<io::Result<()>> {
13784            if let Some(bytes) = self.before.take() {
13785                buf.put_slice(&bytes);
13786                return Poll::Ready(Ok(()));
13787            }
13788            if let Some(gate) = self.gate.as_mut() {
13789                match Pin::new(gate).poll(cx) {
13790                    Poll::Pending => return Poll::Pending,
13791                    Poll::Ready(_) => self.gate = None,
13792                }
13793            }
13794            Pin::new(&mut self.after).poll_read(cx, buf)
13795        }
13796    }
13797
13798    struct DiscardSink;
13799
13800    impl OutputSink for DiscardSink {
13801        fn write_line(&mut self, _line: &[u8]) {}
13802    }
13803
13804    fn line(text: &str) -> TailEntry {
13805        TailEntry::Line {
13806            text: text.to_string(),
13807            truncated: false,
13808            at_ms: None,
13809        }
13810    }
13811
13812    fn lock(ring: &Arc<Mutex<StderrRing>>) -> std::sync::MutexGuard<'_, StderrRing> {
13813        ring.lock().unwrap()
13814    }
13815
13816    /// Start a reader for a new process generation that delivers `before`
13817    /// immediately and `after` only once the returned sender fires (or is
13818    /// dropped).
13819    fn held_pump(
13820        ring: &Arc<Mutex<StderrRing>>,
13821        before: &str,
13822        after: &str,
13823    ) -> (StderrPump, oneshot::Sender<()>) {
13824        let generation = lock(ring).begin_process();
13825        let (release, gate) = oneshot::channel();
13826        let reader = HeldReader {
13827            before: Some(before.as_bytes().to_vec()),
13828            gate: Some(gate),
13829            after: io::Cursor::new(after.as_bytes().to_vec()),
13830        };
13831        let task = tokio::spawn(pump_stderr_to(
13832            reader,
13833            Arc::clone(ring),
13834            generation,
13835            DiscardSink,
13836        ));
13837        (StderrPump { task, generation }, release)
13838    }
13839
13840    async fn wait_until(ring: &Arc<Mutex<StderrRing>>, done: impl Fn(&StderrRing) -> bool) {
13841        for _ in 0..1000 {
13842            if done(&lock(ring)) {
13843                return;
13844            }
13845            tokio::time::sleep(Duration::from_millis(1)).await;
13846        }
13847        panic!(
13848            "ring never reached the expected state: {:?}",
13849            lock(ring).snapshot(None, None)
13850        );
13851    }
13852
13853    #[tokio::test(start_paused = true)]
13854    async fn a_crash_line_the_reader_had_not_reached_by_the_bound_is_kept_before_the_restart() {
13855        let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
13856        let (pump, release) = held_pump(&ring, "booting\n", "config error: missing storage\n");
13857
13858        settle_stderr_pump("crasher", &ring, pump, BOUND).await;
13859        let before_release = lock(&ring).snapshot(None, None);
13860        assert!(
13861            matches!(before_release.capture, CaptureState::Incomplete { .. }),
13862            "a reader that has not reached EOF cannot claim a whole tail: {before_release:?}"
13863        );
13864
13865        // The restart: the next process starts and writes before the old
13866        // reader catches up.
13867        let next = lock(&ring).begin_process();
13868        lock(&ring).push_line_from(next, "next process booting");
13869        release.send(()).unwrap();
13870        wait_until(&ring, |ring| {
13871            ring.snapshot(None, None).capture == CaptureState::Captured
13872        })
13873        .await;
13874
13875        assert_eq!(
13876            untimed(lock(&ring).snapshot(None, None).entries),
13877            vec![
13878                line("booting"),
13879                line("config error: missing storage"),
13880                TailEntry::ProcessStart,
13881                line("next process booting"),
13882            ],
13883            "the crash's last line must survive a slow reader and stay in the crashed process's section"
13884        );
13885    }
13886
13887    #[tokio::test(start_paused = true)]
13888    async fn a_pipe_held_open_by_a_descendant_reads_incomplete_without_delaying_the_restart_past_the_bound(
13889    ) {
13890        let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
13891        // `_held` is never fired: a descendant keeps the pipe open for the
13892        // whole test.
13893        let (pump, _held) = held_pump(&ring, "parent exiting\n", "");
13894
13895        let started = Instant::now();
13896        settle_stderr_pump("orphaning", &ring, pump, BOUND).await;
13897        assert_eq!(
13898            started.elapsed(),
13899            BOUND,
13900            "the restart must wait exactly the bound for a pipe that stays open, no longer"
13901        );
13902
13903        let next = lock(&ring).begin_process();
13904        lock(&ring).push_line_from(next, "next process booting");
13905        tokio::time::sleep(Duration::from_secs(60)).await;
13906
13907        let snapshot = lock(&ring).snapshot(None, None);
13908        match &snapshot.capture {
13909            CaptureState::Incomplete { reason } => assert!(
13910                reason.contains("had not reached EOF") && reason.contains("250ms"),
13911                "the reason must say what is missing and after how long: {reason}"
13912            ),
13913            other => panic!("expected Incomplete while the pipe is held open, got {other:?}"),
13914        }
13915        assert_eq!(
13916            untimed(snapshot.entries),
13917            vec![
13918                line("parent exiting"),
13919                TailEntry::ProcessStart,
13920                line("next process booting"),
13921            ]
13922        );
13923    }
13924
13925    #[tokio::test(start_paused = true)]
13926    async fn a_reader_that_reaches_eof_within_the_bound_leaves_the_tail_captured() {
13927        let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
13928        let (pump, release) = held_pump(&ring, "one\n", "two\n");
13929        release.send(()).unwrap();
13930
13931        settle_stderr_pump("clean", &ring, pump, BOUND).await;
13932
13933        let snapshot = lock(&ring).snapshot(None, None);
13934        assert_eq!(snapshot.capture, CaptureState::Captured);
13935        assert_eq!(untimed(snapshot.entries), vec![line("one"), line("two")]);
13936    }
13937
13938    #[tokio::test(start_paused = true)]
13939    async fn stdout_buffered_past_the_exit_drain_bound_reaches_the_capture_file() {
13940        const BURST: usize = 150;
13941        let capture = cortexkit_test_support::ScratchDir::new("stdout-exit-drain");
13942        let path = capture.join("two-pipe.stderr.log");
13943        let sink = ChildOutputSink::open(&path, cortexkit_log::Retention::default()).unwrap();
13944        let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
13945        let generation = lock(&ring).begin_process();
13946        let stdout = (0..BURST)
13947            .map(|i| format!("out-{i:04}-{}\n", "o".repeat(64)))
13948            .collect::<String>();
13949        let stderr = (0..BURST)
13950            .map(|i| format!("err-{i:04}-{}\n", "e".repeat(64)))
13951            .collect::<String>();
13952        // Model a child that has written both bursts and exited. Hold the stdout
13953        // reader after one line and a partial prefix, as if pipe readiness or
13954        // capture-file writes had delayed it. The final line also needs EOF to
13955        // flush: neither complete nor partial buffered output may be discarded.
13956        let first_chunk = stdout.find('\n').unwrap() + 1 + "out-0001-".len();
13957        let (release, gate) = oneshot::channel();
13958        let reader = HeldReader {
13959            before: Some(stdout.as_bytes()[..first_chunk].to_vec()),
13960            gate: Some(gate),
13961            after: io::Cursor::new(stdout.as_bytes()[first_chunk..stdout.len() - 1].to_vec()),
13962        };
13963        let (finished, completion) = oneshot::channel();
13964        let stdout_sink = sink.clone();
13965        let pump = tokio::spawn(async move {
13966            pump_stdout_to(reader, stdout_sink).await;
13967            finished.send(()).unwrap();
13968        });
13969        pump_stderr_to(io::Cursor::new(stderr.into_bytes()), ring, generation, sink).await;
13970
13971        let started = Instant::now();
13972        settle_stdout_pump("two-pipe", pump, BOUND).await;
13973        assert_eq!(
13974            started.elapsed(),
13975            BOUND,
13976            "a late reader must not hold up restart"
13977        );
13978        assert_eq!(
13979            std::fs::read_to_string(&path).unwrap().lines().count(),
13980            BURST + 1
13981        );
13982
13983        release
13984            .send(())
13985            .expect("stdout reader must survive the exit drain bound");
13986        tokio::time::timeout(Duration::from_secs(1), completion)
13987            .await
13988            .expect("released stdout reader must reach EOF")
13989            .expect("stdout capture must finish at EOF, not be cancelled");
13990        let contents = std::fs::read_to_string(&path).unwrap();
13991        let mut lines = contents
13992            .lines()
13993            .map(|line| split_capture_stamp(line).expect("capture stamp").1)
13994            .collect::<Vec<_>>();
13995        let mut expected = (0..BURST)
13996            .flat_map(|i| {
13997                [
13998                    format!("out-{i:04}-{}", "o".repeat(64)),
13999                    format!("err-{i:04}-{}", "e".repeat(64)),
14000                ]
14001            })
14002            .collect::<Vec<_>>();
14003        lines.sort_unstable();
14004        expected.sort_unstable();
14005        assert_eq!(
14006            lines, expected,
14007            "both pipes must retain every complete line exactly once"
14008        );
14009    }
14010}
14011
14012/// Containment of a module's process tree (issue #109).
14013///
14014/// The behaviour these defend against is a module helper surviving its module:
14015/// on a real machine the Synapse embedding module's CUDA worker holds ~2.2 GB of
14016/// VRAM, so a leaked grandchild is a leaked GPU allocation, and a day of restarts
14017/// compounds it.
14018///
14019/// They run against the SUPERVISOR rather than the job-object crate because the
14020/// claim is about teardown: a crate-level test proves a job can reap a tree, not
14021/// that the daemon's drain path reaches it.
14022///
14023/// Windows-only, like the mechanism. On Unix this arm compiles out; the cgroup
14024/// lane there is a separate containment path with its own tests.
14025#[cfg(all(test, windows))]
14026mod job_containment_tests {
14027    use super::*;
14028    use cortexkit_test_support::ScratchDir;
14029    use std::{
14030        path::{Path, PathBuf},
14031        sync::{Arc, Mutex},
14032        time::{Duration, Instant},
14033    };
14034
14035    /// The stub, expected beside this test executable.
14036    ///
14037    /// The existence check is here for the reason its twin at `fake_aft_stub_path`
14038    /// documents: `--lib` does not build `[[bin]]` targets, and a bare spawn
14039    /// failure then reads as a broken test rather than an unbuilt dependency.
14040    fn stub_path() -> PathBuf {
14041        let mut path = std::env::current_exe().expect("current_exe available in tests");
14042        path.pop();
14043        path.pop();
14044        path.push("fake-aft-stub.exe");
14045        assert!(
14046            path.exists(),
14047            "fake-aft-stub not built at {}: run `cargo test -p subc-core` (which builds \
14048             [[bin]] targets) rather than `cargo test -p subc-core --lib` (which does not)",
14049            path.display()
14050        );
14051        path
14052    }
14053
14054    /// Poll for the grandchild pid the stub records, and parse it.
14055    fn read_grandchild_pid(path: &Path) -> u32 {
14056        let deadline = Instant::now() + Duration::from_secs(10);
14057        loop {
14058            if let Ok(contents) = std::fs::read_to_string(path) {
14059                if let Ok(pid) = contents.trim().parse() {
14060                    return pid;
14061                }
14062            }
14063            assert!(
14064                Instant::now() < deadline,
14065                "the stub never recorded a grandchild pid at {}",
14066                path.display()
14067            );
14068            std::thread::sleep(Duration::from_millis(10));
14069        }
14070    }
14071
14072    /// Everything one fixture run needs, so the two tests below differ in exactly
14073    /// one place: whether the child is contained.
14074    struct Fixture {
14075        _dir: ScratchDir,
14076        module_id: String,
14077        grandchild: u32,
14078        child: Option<SupervisedChild>,
14079        registry: Arc<Registry>,
14080        snapshot: Arc<Mutex<SupervisorSnapshot>>,
14081        terminal_ring: Arc<Mutex<TerminalRing>>,
14082        spawn_events: SpawnEventFeed,
14083    }
14084
14085    fn fixture(label: &str, module_id: &str) -> Fixture {
14086        let dir = ScratchDir::new(label);
14087        let pid_file = dir.join("grandchild.pid");
14088        let supervisor = Supervisor::new_for_test(
14089            Arc::new(Registry::default()),
14090            RestartPolicy::new(3, Duration::ZERO),
14091        );
14092        let runtime = supervisor.runtime_config();
14093        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
14094        let spec = ModuleSpec {
14095            module_id: module_id.to_string(),
14096            program: stub_path(),
14097            // Zero args deliberately: a `--subc` argument would make the stub dial
14098            // a daemon that is not there, and the failure would land in the same
14099            // stderr ring this fixture exists to keep quiet.
14100            args: Vec::new(),
14101            env: vec![
14102                ("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string()),
14103                (
14104                    "FAKE_AFT_GRANDCHILD_PID_FILE".to_string(),
14105                    pid_file.display().to_string(),
14106                ),
14107            ],
14108            reserved: false,
14109            reserved_prefixes: Vec::new(),
14110            protocol: ModuleProtocol::Subc,
14111            overlap: Default::default(),
14112        };
14113        let child = spawn_and_mark_running(&spec, &runtime, &snapshot)
14114            .expect("spawn the supervised fixture");
14115        let grandchild = read_grandchild_pid(&pid_file);
14116        Fixture {
14117            _dir: dir,
14118            module_id: module_id.to_string(),
14119            grandchild,
14120            child: Some(child),
14121            registry: Arc::new(Registry::default()),
14122            snapshot,
14123            terminal_ring: Arc::clone(&runtime.terminal_ring),
14124            spawn_events: SpawnEventFeed::default(),
14125        }
14126    }
14127
14128    impl Fixture {
14129        /// Drain through the supervisor's own teardown path.
14130        async fn drain(&mut self) {
14131            let child = self
14132                .child
14133                .take()
14134                .expect("the fixture child is still present");
14135            drain_child_to_state(
14136                &self.module_id,
14137                ModuleProtocol::Subc,
14138                // No forwarding table in this fixture, so nothing reaches the
14139                // child over a connection.
14140                StopNotice::NotSent,
14141                &self.registry,
14142                None,
14143                &self.snapshot,
14144                &self.terminal_ring,
14145                &self.spawn_events,
14146                child,
14147                Duration::from_millis(500),
14148                ModuleState::Stopped,
14149                Some(false),
14150            )
14151            .await
14152            .expect("drain the supervised fixture");
14153        }
14154    }
14155
14156    /// Teardown reaps the grandchild, not merely the direct child.
14157    ///
14158    /// This is the assertion the change exists for. Before containment the
14159    /// grandchild survived: it is a separate process, and `start_kill` is
14160    /// `TerminateProcess` scoped to one pid.
14161    #[tokio::test]
14162    async fn teardown_reaps_the_grandchild() {
14163        let mut fixture = fixture("teardown-grandchild", "tree-teardown");
14164        let grandchild = fixture.grandchild;
14165
14166        assert!(
14167            subc_jobobject::process_exists(grandchild),
14168            "grandchild {grandchild} must be alive before teardown, or this proves nothing"
14169        );
14170
14171        fixture.drain().await;
14172
14173        assert!(
14174            subc_jobobject::wait_for_process_exit(grandchild, Duration::from_secs(10)),
14175            "grandchild {grandchild} outlived module teardown: the tree was not contained"
14176        );
14177    }
14178
14179    /// The mutation control: with containment withheld, the grandchild survives
14180    /// the same kill.
14181    ///
14182    /// This is the defect reproduction from #109 — a direct-child kill reaches
14183    /// one pid, and the grandchild is a different process. It spawns OUTSIDE the
14184    /// supervisor because `spawn_and_mark_running` now always contains on
14185    /// Windows, which is the point: there is no longer a path that spawns
14186    /// uncontained, so the control has to construct one.
14187    ///
14188    /// Its job is to keep `teardown_reaps_the_grandchild` honest. If the
14189    /// grandchild ever dies here, that test is passing for a reason unrelated to
14190    /// the job object and the containment claim is unproven.
14191    #[test]
14192    fn an_uncontained_grandchild_survives_a_direct_child_kill() {
14193        let dir = ScratchDir::new("teardown-uncontained");
14194        let pid_file = dir.join("grandchild.pid");
14195        let mut child = std::process::Command::new(stub_path())
14196            .env("FAKE_AFT_NEVER_CONNECT", "1")
14197            .env(
14198                "FAKE_AFT_GRANDCHILD_PID_FILE",
14199                pid_file.display().to_string(),
14200            )
14201            .stdin(std::process::Stdio::null())
14202            .stdout(std::process::Stdio::null())
14203            .stderr(std::process::Stdio::null())
14204            .spawn()
14205            .expect("spawn the uncontained fixture");
14206        let grandchild = read_grandchild_pid(&pid_file);
14207
14208        // Exactly what the pre-fix teardown did: kill the direct child.
14209        child.kill().expect("kill the direct child");
14210        let _ = child.wait();
14211
14212        assert!(
14213            subc_jobobject::process_exists(grandchild),
14214            "grandchild {grandchild} died with the direct child, so this control no longer \
14215             distinguishes contained from uncontained teardown and the regression test is \
14216             passing vacuously"
14217        );
14218
14219        // The orphan this control demonstrates is the leak the fix prevents, so
14220        // the control must not leave one behind.
14221        kill_tree(grandchild);
14222    }
14223
14224    /// Crash durability: closing the containment handle reaps the tree with no
14225    /// teardown code running at all.
14226    ///
14227    /// This is the case `taskkill /T` cannot cover — a daemon that dies cannot
14228    /// call anything — and it is why containment is a kernel property of the
14229    /// handle rather than a step in the drain. Discovered by getting the
14230    /// mutation control wrong: clearing `job` to "disable" containment instead
14231    /// killed the tree, which is the guarantee, not a mistake.
14232    #[tokio::test]
14233    async fn dropping_containment_reaps_the_grandchild() {
14234        let mut fixture = fixture("drop-containment", "tree-drop");
14235        let grandchild = fixture.grandchild;
14236
14237        assert!(subc_jobobject::process_exists(grandchild));
14238
14239        // No `drain` call, no kill: dropping the handle is the entire mechanism.
14240        fixture.child.as_mut().expect("child present").job = None;
14241
14242        assert!(
14243            subc_jobobject::wait_for_process_exit(grandchild, Duration::from_secs(10)),
14244            "grandchild {grandchild} survived the containment handle closing, so a daemon \
14245             crash would leave the tree behind"
14246        );
14247    }
14248
14249    /// Kill a pid and its tree, then confirm it is gone.
14250    fn kill_tree(pid: u32) {
14251        let _ = std::process::Command::new("taskkill.exe")
14252            .args(["/PID", &pid.to_string(), "/T", "/F"])
14253            .stdin(std::process::Stdio::null())
14254            .stdout(std::process::Stdio::null())
14255            .stderr(std::process::Stdio::null())
14256            .status();
14257        assert!(
14258            subc_jobobject::wait_for_process_exit(pid, Duration::from_secs(10)),
14259            "could not clean up grandchild {pid}"
14260        );
14261    }
14262}
14263
14264#[cfg(test)]
14265mod privacy_trampoline_configuration_tests {
14266    #[tokio::test]
14267    #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
14268    async fn macos_spawn_without_a_configured_trampoline_refuses_by_name() {
14269        #[cfg(target_os = "macos")]
14270        {
14271            let supervisor = super::Supervisor::new(
14272                std::sync::Arc::new(crate::Registry::default()),
14273                super::RestartPolicy::default(),
14274            );
14275            let error = supervisor.spawn(spec()).unwrap_err();
14276            assert!(
14277                error
14278                    .to_string()
14279                    .contains("no privacy trampoline configured"),
14280                "{error}"
14281            );
14282        }
14283    }
14284
14285    #[tokio::test]
14286    #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
14287    async fn macos_wrong_trampoline_is_rejected_by_the_startup_probe() {
14288        #[cfg(target_os = "macos")]
14289        {
14290            let supervisor = super::Supervisor::new(
14291                std::sync::Arc::new(crate::Registry::default()),
14292                super::RestartPolicy::default(),
14293            )
14294            .with_privacy_trampoline(std::env::current_exe().unwrap());
14295            let error = supervisor.spawn(spec()).unwrap_err();
14296            assert!(
14297                error
14298                    .to_string()
14299                    .contains("binary does not implement the privacy trampoline protocol"),
14300                "{error}"
14301            );
14302        }
14303    }
14304
14305    #[cfg(target_os = "macos")]
14306    fn spec() -> super::ModuleSpec {
14307        super::ModuleSpec {
14308            module_id: "privacy-configuration".into(),
14309            program: "/bin/sleep".into(),
14310            args: vec!["30".into()],
14311            env: vec![],
14312            reserved: false,
14313            reserved_prefixes: vec![],
14314            protocol: subc_control::ModuleProtocol::None,
14315            overlap: super::ModuleOverlap::Exclusive,
14316        }
14317    }
14318}
14319
14320#[cfg(test)]
14321mod privacy_exec_boundary_tests {
14322    #[cfg(target_os = "macos")]
14323    use super::*;
14324    #[cfg(target_os = "macos")]
14325    use std::{
14326        io::{Read, Write},
14327        net::{TcpListener, TcpStream},
14328    };
14329
14330    /// Unit-test-only pause at the actual early image read, not at a later
14331    /// status read. Production supervisors never inspect this environment key.
14332    #[cfg(target_os = "macos")]
14333    pub(super) fn before_image_sample(spec: &ModuleSpec, pid: u32) {
14334        if let Some((_, path)) = spec
14335            .env
14336            .iter()
14337            .find(|(key, _)| key == "SUBC_TEST_PRIVACY_SAMPLE_BARRIER")
14338        {
14339            let mut barrier = TcpStream::connect(path).unwrap();
14340            barrier
14341                .set_read_timeout(Some(Duration::from_secs(30)))
14342                .unwrap();
14343            barrier.write_all(&pid.to_ne_bytes()).unwrap();
14344            let mut release = [0];
14345            barrier.read_exact(&mut release).unwrap();
14346            assert_eq!(&release, b"X");
14347        }
14348    }
14349
14350    #[cfg(target_os = "macos")]
14351    fn spec(program: &str, args: &[&str]) -> ModuleSpec {
14352        ModuleSpec {
14353            module_id: "privacy-boundary".into(),
14354            program: program.into(),
14355            args: args.iter().map(|arg| (*arg).into()).collect(),
14356            env: vec![],
14357            reserved: false,
14358            reserved_prefixes: vec![],
14359            protocol: ModuleProtocol::None,
14360            overlap: ModuleOverlap::Exclusive,
14361        }
14362    }
14363
14364    #[cfg(target_os = "macos")]
14365    async fn accept(listener: TcpListener) -> TcpStream {
14366        // Socket readiness, not elapsed time, establishes both pause points.
14367        let listener = tokio::net::TcpListener::from_std({
14368            listener.set_nonblocking(true).unwrap();
14369            listener
14370        })
14371        .unwrap();
14372        let (stream, _) = tokio::time::timeout(Duration::from_secs(30), listener.accept())
14373            .await
14374            .unwrap()
14375            .unwrap();
14376        let stream = stream.into_std().unwrap();
14377        stream.set_nonblocking(false).unwrap();
14378        stream
14379            .set_read_timeout(Some(Duration::from_secs(30)))
14380            .unwrap();
14381        stream
14382    }
14383
14384    #[tokio::test]
14385    #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
14386    async fn macos_roster_withholds_a_nonnull_trampoline_image_until_exec_confirmation() {
14387        #[cfg(target_os = "macos")]
14388        {
14389            let root = cortexkit_test_support::ScratchDir::new("privacy-roster-barrier");
14390            // Loopback sockets also work when the replay adapter's TMPDIR is
14391            // longer than Darwin's Unix-domain socket path limit.
14392            let exec_listener = TcpListener::bind("127.0.0.1:0").unwrap();
14393            let sample_listener = TcpListener::bind("127.0.0.1:0").unwrap();
14394            let record = root.join("live-children.json");
14395            let supervisor =
14396                Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
14397                    .with_live_children_record(&record);
14398            let runtime = supervisor.runtime_config();
14399            let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
14400            let mut spec = spec("/bin/sleep", &["30"]);
14401            spec.env = vec![
14402                (
14403                    "SUBC_TEST_PRIVACY_EXEC_BARRIER".into(),
14404                    exec_listener.local_addr().unwrap().to_string(),
14405                ),
14406                (
14407                    "SUBC_TEST_PRIVACY_SAMPLE_BARRIER".into(),
14408                    sample_listener.local_addr().unwrap().to_string(),
14409                ),
14410            ];
14411            let spawn = tokio::task::spawn_blocking(move || {
14412                spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap()
14413            });
14414            let mut sample = accept(sample_listener).await;
14415            let mut pid = [0; 4];
14416            sample.read_exact(&mut pid).unwrap();
14417            let pid = u32::from_ne_bytes(pid);
14418            let mut exec = accept(exec_listener).await;
14419            let mut ready = [0];
14420            exec.read_exact(&mut ready).unwrap();
14421            assert_eq!(&ready, b"R");
14422            // The early read is guaranteed to see a real, non-null trampoline
14423            // image: the fixture has reached its barrier and cannot exec yet.
14424            let trampoline = subc_os::file_identity(&test_privacy_trampoline()).unwrap();
14425            assert_eq!(
14426                observe_spawned_image(pid).unwrap().executable,
14427                Some(trampoline)
14428            );
14429            sample.write_all(b"X").unwrap();
14430            let mut child = spawn.await.unwrap();
14431            let early = crate::live_children::read_record(&record).unwrap();
14432            assert_eq!(early.len(), 1);
14433            assert_eq!(early[0].pid, pid);
14434            assert_eq!(
14435                early[0].executable, None,
14436                "unconfirmed trampoline image entered the roster"
14437            );
14438            assert!(child.report_ready.get().is_none());
14439            // The barrier's duration is unrelated to the production five-second
14440            // exec budget. Start the test's confirmation budget upon release.
14441            child.privacy_exec.as_mut().unwrap().deadline =
14442                tokio::time::Instant::now() + Duration::from_secs(30);
14443            exec.write_all(b"X").unwrap();
14444            child.confirm_privacy_exec().await;
14445            assert_eq!(child.spawn_failure, None);
14446            assert!(child.report_ready.get().is_some());
14447            let confirmed = crate::live_children::read_record(&record).unwrap();
14448            let module = subc_os::file_identity(std::path::Path::new("/bin/sleep")).unwrap();
14449            assert_ne!(module, trampoline);
14450            assert_eq!(confirmed[0].executable, Some(module.into()));
14451            child.start_kill().unwrap();
14452            child.wait().await.unwrap();
14453            child.release_roster();
14454        }
14455    }
14456
14457    #[tokio::test]
14458    #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
14459    async fn macos_already_exited_121_in_try_wait_is_a_module_exit_not_a_trampoline_refusal() {
14460        #[cfg(target_os = "macos")]
14461        {
14462            let registry = Arc::new(Registry::default());
14463            let policy = RestartPolicy::new(0, Duration::ZERO);
14464            let supervisor = Supervisor::new_for_test(Arc::clone(&registry), policy);
14465            let runtime = supervisor.runtime_config();
14466            let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
14467            let spec = spec("/bin/sh", &["-c", "exit 121"]);
14468            // Drive spawn and confirmation separately instead of starting the
14469            // monitor. WNOWAIT observes a real exit without consuming its status,
14470            // so confirmation's first try_wait must take the already-exited arm.
14471            let mut child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
14472            let pid = child.pid;
14473            tokio::task::spawn_blocking(move || {
14474                subc_os::fork_exec_test::wait_for_child_exit_without_reaping(pid)
14475            })
14476            .await
14477            .unwrap()
14478            .unwrap();
14479            child.privacy_exec.as_mut().unwrap().deadline =
14480                tokio::time::Instant::now() + Duration::from_secs(30);
14481            let status = child.wait().await.unwrap();
14482            assert_eq!(status.code(), Some(121));
14483            assert!(child.privacy_exec.is_none());
14484            assert!(
14485                child.report_ready.get().is_none(),
14486                "an exited module must not publish a live pid"
14487            );
14488            let report = classify_reaped_child_exit(&snapshot, &child, &status);
14489            on_child_exit(
14490                &spec,
14491                policy,
14492                &registry,
14493                &snapshot,
14494                &runtime.terminal_ring,
14495                &runtime.spawn_events,
14496                &runtime.child_roster,
14497                report,
14498            )
14499            .await;
14500            let state = lock_snapshot(&snapshot).unwrap();
14501            assert_eq!(state.state, ModuleState::Failed);
14502            assert_eq!(state.last_exit.as_ref().unwrap().code, Some(121));
14503            assert_eq!(state.reported_pid(), None);
14504            drop(state);
14505            let history = runtime.terminal_ring.lock().unwrap().snapshot();
14506            assert_eq!(history.entries.len(), 1);
14507            let terminal = &history.entries[0];
14508            assert_eq!(terminal.exit_code, Some(121));
14509            assert_eq!(terminal.exit_kind, subc_control::TerminalExitKind::Crash);
14510            assert_eq!(terminal.disposition, TerminalDisposition::Failed);
14511            assert_eq!(
14512                terminal.disposition_detail.as_deref(),
14513                Some(policy.budget_exhausted_detail().as_str()),
14514                "module exit 121 was classified as a trampoline refusal: {terminal:?}"
14515            );
14516            assert_eq!(child.spawn_failure, None);
14517            child.release_roster();
14518        }
14519    }
14520}
14521
14522/// The daemon's real spawn path hands a subc-wire child its launch nonce on
14523/// descriptor 3, without an environment copy. The shell records the nonce
14524/// and its environment after exec so these tests observe the real handover.
14525#[cfg(all(test, unix))]
14526mod launch_nonce_descriptor_tests {
14527    use super::{ChildRoster, ModuleProtocol, ModuleSpec, SupervisorHandle};
14528    use crate::stderr_tail::{StderrRing, StderrTailConfig};
14529    use cortexkit_test_support::ScratchDir;
14530    use std::{
14531        path::PathBuf,
14532        sync::{Arc, Mutex},
14533        time::{Duration, Instant},
14534    };
14535
14536    async fn probe(role: super::SpawnRole) {
14537        let scratch = ScratchDir::new("launch-nonce-descriptor");
14538        let fd_copy = scratch.join("from-descriptor");
14539        let env_copy = scratch.join("environment");
14540        let script = format!(
14541            "env > '{env}.tmp' && mv '{env}.tmp' '{env}'; cat <&3 > '{fd}.tmp' && mv '{fd}.tmp' '{fd}'; sleep 30",
14542            fd = fd_copy.display(), env = env_copy.display(),
14543        );
14544        let xdg = |name: &str| (name.to_string(), scratch.join(name).display().to_string());
14545        let spec = ModuleSpec {
14546            module_id: "nonce-descriptor-probe".to_string(),
14547            program: PathBuf::from("/bin/sh"),
14548            args: vec!["-c".to_string(), script],
14549            env: vec![
14550                xdg("XDG_DATA_HOME"),
14551                xdg("XDG_RUNTIME_DIR"),
14552                xdg("XDG_CONFIG_HOME"),
14553                ("SUBC_LAUNCH_NONCE".to_string(), "must-not-leak".to_string()),
14554            ],
14555            reserved: true,
14556            reserved_prefixes: Vec::new(),
14557            protocol: ModuleProtocol::Subc,
14558            overlap: Default::default(),
14559        };
14560        let handle = SupervisorHandle::new();
14561        let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
14562        let roster = ChildRoster::default();
14563        #[cfg(target_os = "macos")]
14564        {
14565            let path = super::test_privacy_trampoline();
14566            roster.set_privacy_trampoline(super::probe_privacy_trampoline(&path).map(|()| path));
14567        }
14568        let child = super::spawn_child_in_slot(
14569            &spec,
14570            None,
14571            Some(&handle),
14572            &ring,
14573            None,
14574            &roster,
14575            #[cfg(target_os = "linux")]
14576            None,
14577            role,
14578            matches!(role, super::SpawnRole::SwapCandidate),
14579        )
14580        .expect("spawn probe");
14581        let deadline = Instant::now() + Duration::from_secs(10);
14582        while !(fd_copy.exists() && env_copy.exists()) {
14583            assert!(Instant::now() < deadline, "probe never wrote its copies");
14584            tokio::time::sleep(Duration::from_millis(20)).await;
14585        }
14586        let nonce = std::fs::read_to_string(fd_copy).unwrap();
14587        assert!(!nonce.is_empty());
14588        let environment = std::fs::read_to_string(env_copy).unwrap();
14589        assert!(environment
14590            .lines()
14591            .any(|line| line.starts_with("SUBC_LAUNCH_NONCE_FD=3:")));
14592        let copy = environment
14593            .lines()
14594            .find_map(|line| line.strip_prefix("SUBC_LAUNCH_NONCE="));
14595        assert_eq!(
14596            copy, None,
14597            "Unix children must never receive the environment nonce"
14598        );
14599        if matches!(role, super::SpawnRole::Plain) {
14600            assert_eq!(
14601                handle.spawn_nonce(&spec.module_id).as_deref(),
14602                Some(nonce.as_str())
14603            );
14604        }
14605        drop(child);
14606    }
14607
14608    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
14609    async fn a_spawned_module_receives_its_nonce_only_on_descriptor_3() {
14610        probe(super::SpawnRole::Plain).await;
14611    }
14612
14613    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
14614    async fn swap_candidate_receives_its_nonce_only_on_descriptor_3() {
14615        probe(super::SpawnRole::SwapCandidate).await;
14616    }
14617}
14618
14619#[cfg(all(test, target_os = "linux"))]
14620mod cgroup_containment_tests {
14621    use super::*;
14622    use cortexkit_test_support::ScratchDir;
14623
14624    fn running(pid: u32) -> bool {
14625        // An orphan can remain a zombie until the container init reaps it.
14626        std::fs::read_to_string(format!("/proc/{pid}/stat"))
14627            .ok()
14628            .and_then(|stat| {
14629                stat.rsplit_once(") ")
14630                    .map(|(_, rest)| rest.starts_with('Z'))
14631            })
14632            .is_some_and(|zombie| !zombie)
14633    }
14634
14635    #[tokio::test]
14636    async fn linux_teardown_reaps_the_grandchild() {
14637        teardown_tree("linux_teardown_reaps_the_grandchild", false).await;
14638    }
14639
14640    #[tokio::test]
14641    async fn linux_shutdown_straggler_reaps_the_grandchild() {
14642        teardown_tree("linux_shutdown_straggler_reaps_the_grandchild", true).await;
14643    }
14644
14645    async fn teardown_tree(test_name: &str, shutdown: bool) {
14646        let dir = ScratchDir::new(test_name);
14647        let root = PathBuf::from(format!(
14648            "/sys/fs/cgroup/subc-tree-test-{test_name}-{}-{}",
14649            std::process::id(),
14650            unix_ms_now()
14651        ));
14652        if let Err(error) = std::fs::create_dir(&root) {
14653            assert!(
14654                std::env::var_os("SUBC_REQUIRE_CGROUP_TEST").is_none(),
14655                "required cgroup test cannot execute: {error}"
14656            );
14657            eprintln!(
14658                "SKIP {test_name}: no writable delegated cgroup at {}: {error}",
14659                root.display()
14660            );
14661            return;
14662        }
14663        let placement = subc_cgroup::prepare_at(&root)
14664            .expect("prepare isolated kernel cgroup")
14665            .expect("isolated cgroup is delegated");
14666        let module_id = "tree-teardown";
14667        let module = placement
14668            .module_path(module_id)
14669            .expect("create isolated module cgroup");
14670        if !module.join("cgroup.kill").exists() {
14671            std::fs::remove_dir(&module).unwrap();
14672            std::fs::remove_dir(root.join("subc-modules")).unwrap();
14673            std::fs::remove_dir(&root).unwrap();
14674            assert!(
14675                std::env::var_os("SUBC_REQUIRE_CGROUP_TEST").is_none(),
14676                "required cgroup.kill interface unavailable"
14677            );
14678            eprintln!("SKIP {test_name}: cgroup.kill unavailable (kernel < 5.14)");
14679            return;
14680        }
14681        let supervisor = Supervisor::new_for_test(
14682            Arc::new(Registry::default()),
14683            RestartPolicy::new(3, Duration::ZERO),
14684        )
14685        .with_cgroup_placement(Some(placement));
14686        let mut runtime = supervisor.runtime_config();
14687        runtime.child_roster = runtime
14688            .child_roster
14689            .for_module(Arc::new(Mutex::new(Duration::from_millis(100))));
14690        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
14691        let pid_file = dir.join("grandchild.pid");
14692        let spec = ModuleSpec {
14693            module_id: module_id.to_string(),
14694            program: PathBuf::from("/bin/sh"),
14695            args: vec![
14696                "-c".into(),
14697                "trap '' TERM; sleep 600 & echo $! > \"$1\"; wait".into(),
14698                "fixture".into(),
14699                pid_file.display().to_string(),
14700            ],
14701            env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
14702                .into_iter()
14703                .map(|key| (key.to_string(), dir.display().to_string()))
14704                .collect(),
14705            reserved: false,
14706            reserved_prefixes: Vec::new(),
14707            protocol: ModuleProtocol::None,
14708            overlap: Default::default(),
14709        };
14710        let child =
14711            spawn_and_mark_running(&spec, &runtime, &snapshot).expect("spawn supervised tree");
14712        let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
14713        let grandchild: u32 = loop {
14714            if let Ok(contents) = std::fs::read_to_string(&pid_file) {
14715                if let Ok(pid) = contents.trim().parse() {
14716                    break pid;
14717                }
14718            }
14719            assert!(
14720                tokio::time::Instant::now() < deadline,
14721                "grandchild pid was not recorded"
14722            );
14723            tokio::time::sleep(Duration::from_millis(10)).await;
14724        };
14725        assert!(
14726            running(grandchild),
14727            "grandchild must be alive before teardown"
14728        );
14729        if shutdown {
14730            let mut child = child;
14731            crate::child_roster::end_children_for_daemon_shutdown(
14732                &runtime.child_roster,
14733                false,
14734                std::future::pending(),
14735            )
14736            .await;
14737            child.wait().await.expect("reap shutdown straggler");
14738        } else {
14739            drain_child_to_state(
14740                module_id,
14741                ModuleProtocol::None,
14742                StopNotice::NotSent,
14743                &Registry::default(),
14744                None,
14745                &snapshot,
14746                &runtime.terminal_ring,
14747                &SpawnEventFeed::default(),
14748                child,
14749                Duration::from_millis(100),
14750                ModuleState::Stopped,
14751                Some(false),
14752            )
14753            .await
14754            .expect("real supervisor teardown");
14755        }
14756        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
14757        while running(grandchild) && tokio::time::Instant::now() < deadline {
14758            tokio::time::sleep(Duration::from_millis(10)).await;
14759        }
14760        let survived = running(grandchild);
14761        // Kill a surviving grandchild so a failed test does not leave it behind.
14762        if survived {
14763            let pid = rustix::process::Pid::from_raw(grandchild as i32).unwrap();
14764            let _ = rustix::process::kill_process(pid, rustix::process::Signal::KILL);
14765            tokio::time::sleep(Duration::from_millis(100)).await;
14766        }
14767        if module.exists() {
14768            std::fs::remove_dir(&module).expect("remove empty module cgroup");
14769        }
14770        std::fs::remove_dir(root.join("subc-modules")).unwrap();
14771        std::fs::remove_dir(&root).unwrap();
14772        assert!(
14773            !survived,
14774            "grandchild {grandchild} outlived module teardown"
14775        );
14776        eprintln!("EXECUTED {test_name}: grandchild {grandchild} killed in isolated cgroup");
14777    }
14778}